public inbox for [email protected]  
help / color / mirror / Atom feed
pg_plan_advice
133+ messages / 27 participants
[nested] [flat]

* pg_plan_advice
@ 2025-10-30 14:00  Robert Haas <[email protected]>
  0 siblings, 3 replies; 133+ messages in thread

From: Robert Haas @ 2025-10-30 14:00 UTC (permalink / raw)
  To: PostgreSQL Hackers <[email protected]>

As I have mentioned on previous threads, for the past while I have
been working on planner extensibility. I've posted some extensibility
patches previously, and got a few of them committed in
Sepember/October with Tom's help, but I think the time has come a
patch which actually makes use of that infrastructure as well as some
further infrastructure that I'm also including in this posting.[1] The
final patch in this series adds a new contrib module called
pg_plan_advice. Very briefly, what pg_plan_advice knows how to do is
process a plan and emits a (potentially long) long text string in a
special-purpose mini-language that describes a bunch of key planning
decisions, such as the join order, selected join methods, types of
scans used to access individual tables, and where and how
partitionwise join and parallelism were used. You can then set
pg_plan_advice.advice to that string to get a future attempt to plan
the same query to reproduce those decisions, or (maybe a better idea)
you can trim that string down to constrain some decisions (e.g. the
join order) but not others (e.g. the join methods), or (if you want to
make your life more exciting) you can edit that advice string and
thereby attempt to coerce the planner into planning the query the way
you think best. There is a README that explains the design philosophy
and thinking in a lot more detail, which is a good place to start if
you're curious, and I implore you to read it if you're interested, and
*especially* if you're thinking of flaming me.

But that doesn't mean that you *shouldn't* flame me. There are a
remarkable number of things that someone could legitimately be unhappy
about in this patch set. First, any form of user control over the
planner tends to be a lightning rod for criticism around here. I've
come to believe that's the wrong way of thinking about it: we can want
to improve the planner over the long term and *also* want to have
tools available to work around problems with it in the short term.
Further, we should not imagine that we're going to solve problems that
have stumped other successful database projects any time in the
foreseeable future; no product will ever get 100% of cases right, and
you don't need to get to very obscure cases before other products
throw up their hands just as we do. But, second, even if you're OK
with the idea of some kind of user control over the planner, you could
very well be of the opinion that what I've implemented here is utter
crap. I've certainly had to make a ton of very opinionated decisions
to get to this point, and you are entitled to hate them. Of course, I
did have *reasons* for making the decisions, so if your operating
theory as to why I did something is that I'm a stupid moron, perhaps
consider an alternative explanation or two as well. Finally, even if
you're OK with the concept and feel that I've made some basically
reasonable design decisions, you might notice that the code is full of
bugs, needs a lot of cleanup, is missing features, lacks
documentation, and a bunch of other stuff. In that judgement, you
would be absolutely correct. I'm not posting it here because I'm
hoping to get it committed in November -- or at least, not THIS
November.  What I would like to do is getting some design feedback on
the preliminary patches, which I think will be more possible if
reviewers also have the main pg_plan_advice to look at as a way of
understanding why the exist, and also some feedback on the
pg_plan_advice patch itself.

Now I do want to caveat the statement that I am looking for feedback
just a little bit. I imagine that there will be some people reading
this who are already imagining how great life will be when they put
this into production, and begin complaining about either (1) features
that it's missing or (2) things that they don't like about the design
of the advice mini-language. What I'd ask you to keep in mind is that
you will not be able to put this into production unless and until
something gets committed, and getting this committed is probably going
to be super-hard even if you don't creep the scope, so maybe don't do
that, especially if you haven't read the README yet to understand what
the scope is actually intended to be. The details of the advice
mini-language are certainly open to negotiation; of everything, that
would be one of the easier things to change. However, keep in mind
that there are probably LOTS AND LOTS of people who all have their own
opinions about what decisions I should have made when designing that
mini-language, and an outcome where you personally get everything you
want and everyone who disagrees is out of luck is unlikely. In other
words, constructive suggestions for improvement are welcome, but
please think twice before turning this into a bikeshedding nightmare.
Now is the time to talk about whether I've got the overall design
somewhat correct moreso than whether I've spelled everything the way
you happen to prefer.[2]

I want to mention that, beyond the fact that I'm sure some people will
want to use something like this (with more feature and a lot fewer
bugs) in production, it seems to be super-useful for testing. We have
a lot of regression test cases that try to coerce the planner to do a
particular thing by manipulating enable_* GUCs, and I've spent a lot
of time trying to do similar things by hand, either for regression
test coverage or just private testing. This facility, even with all of
the bugs and limitations that it currently has, is exponentially more
powerful than frobbing enable_* GUCs. Once you get the hang of the
advice mini-language, you can very quickly experiment with all sorts
of plan shapes in ways that are currently very hard to do, and thereby
find out how expensive the planner thinks those things are and which
ones it thinks are even legal. So I see this as not only something
that people might find useful for in production deployments, but also
something that can potentially be really useful to advance PostgreSQL
development.

Which brings me to the question of where this code ought to go if it
goes anywhere at all. I decided to propose pg_plan_advice as a contrib
module rather than a part of core because I had to make a WHOLE lot of
opinionated design decisions just to get to the point of having
something that I could post and hopefully get feedback on. I figured
that all of those opinionated decisions would be a bit less
unpalatable if they were mostly encapsulated in a contrib module, with
the potential for some future patch author to write a different
contrib module that adopted different solutions to all of those
problems. But what I've also come to realize is that there's so much
infrastructure here that leaving the next person to reinvent it may
not be all that appealing. Query jumbling is a previous case where we
initially thought that different people might want to do different
things, but eventually realized that most people really just wanted
some solution that they didn't have to think too hard about. Likewise,
in this patch, the relation identifier system described in the README
is the only thing of its kind, to my knowledge, and any system that
wants to accomplish something similar to what pg_plan_advice does
would need a system like that. pg_hint_plan doesn't have something
like that, because pg_hint_plan is just trying to do hints. This is
trying to do round-trip-safe plan stability, where the system will
tell you how to refer unambiguously to a certain part of the query in
a way that will work correctly on every single query regardless of how
it's structured or how many times it refers to the same tables or to
different tables using the same aliases. If we say that we're never
going to put any of that infrastructure in core, then anyone who wants
to write a module to control the planner is going to need to start by
either (a) reinventing something similar, (b) cloning all the relevant
code, or (c) just giving up on the idea of unambiguous references to
parts of a query. None of those seem like great options, so now I'm
less sure whether contrib is actually the right place for this code,
but that's where I have put it for now. Feedback welcome, on this and
everything else.

Perhaps more than any other patch I've ever written, I know I'm
playing with fire here just by putting this out on the list, but I'm
nevertheless hopeful that something good can come of it, and I hope we
can have a constructive discussion about what that thing should be. I
think there is unquestionably is a lot of demand for the ability to
influence the planner in some form, but there is a lot of room for
debate about what exactly that should mean in practice. While I
personally am pretty happy with the direction of the code I've
written, modulo the large amount of not-yet-completed bug fixing and
cleanup, there's certainly plenty of room for other people to feel
differently, and finding out what other people think is, of course,
the whole point of posting things publicly before committing them --
or in this case, before even finishing them.[3] If you're interested
it contributing to the conversation, I urge you to start with the
following things: (1) the README in the final patch; (2) the
regression test examples in the final patch, which give a good sense
of what it actually looks like to use this; and (3) the earlier
patches, which show the minimum amount of core infrastructure that I
think we need in order to make something like this workable (ideas on
how to further reduce that footprint are very welcome).

Thanks,

--
Robert Haas
EDB: http://www.enterprisedb.com

[1] All of the earlier patches have been posted previously in some
form, but the commit messages have been rewritten for clarity, and the
"Allow for plugin control over path generation strategies" patch has
been heavily rewritten since it was last posted; the earlier versions
turned out to have substantial inadequacies.

[2] This is not to say that proposal to modify or improve the syntax
are unwelcome, but the bigger obstacle to getting something committed
here is probably reaching some agreement on the internal details. Any
changes to src/include/optimizer or src/backend/optimizer need careful
scrutiny from a design perspective. Also, keep in mind that the syntax
needs to fit what we can actually do: a proposal to change the syntax
to something that implies semantics we can't implement is a dead
letter.

[3] Note, however, that a proposal to achieve the same or similar
goals by different means is more welcome than a proposal that I should
have done some other project entirely. I've already put a lot of work
into these goals and hope to achieve them, at least to some degree,
before I start working toward something else.


Attachments:

  [application/octet-stream] v1-0005-Allow-for-plugin-control-over-path-generation-str.patch (55.4K, 2-v1-0005-Allow-for-plugin-control-over-path-generation-str.patch)
  download | inline diff:
From 4a615d04f5c334a5105dc85c8d0e9bb6ff3a7aa6 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 24 Oct 2025 15:11:47 -0400
Subject: [PATCH v1 5/6] Allow for plugin control over path generation
 strategies.

Each RelOptInfo now has a pgs_mask member which is a mask of acceptable
strategies. For most rels, this is populated from PlannerGlobal's
default_pgs_mask, which is computed from the values of the enable_*
GUCs at the start of planning.

For baserels, get_relation_info_hook can be used to adjust pgs_mask for
each new RelOptInfo, at least for rels of type RTE_RELATION. Adjusting
pgs_mask is less useful for other types of rels, but if it proves to
be necessary, we can revisit the way this hook works or add a new one.

For joinrels, two new hooks are added. joinrel_setup_hook is called each
time a joinrel is created, and one thing that can be done from that hook
is to manipulate pgs_mask for the new joinrel. join_path_setup_hook is
called each time we're about to add paths to a joinrel by considering
some particular combination of an outer rel, an inner rel, and a join
type. It can modify the pgs_mask propagated into JoinPathExtraData to
restrict strategy choice for that paricular combination of rels.

To make joinrel_setup_hook work as intended, the existing calls to
build_joinrel_partition_info are moved later in the calling functions;
this is because that function checks whether the rel's pgs_mask includes
PGS_CONSIDER_PARTITIONWISE, so we want it to only be called after
plugins have had a chance to alter pgs_mask.

Upper rels currently inherit pgs_mask from the input relation. It's
unclear that this is the most useful behavior, but at the moment there
are no hooks to allow the mask to be set in any other way.
---
 src/backend/optimizer/path/allpaths.c   |   2 +-
 src/backend/optimizer/path/costsize.c   | 221 ++++++++++++++++++------
 src/backend/optimizer/path/indxpath.c   |   4 +-
 src/backend/optimizer/path/joinpath.c   |  88 +++++++---
 src/backend/optimizer/path/tidpath.c    |   7 +-
 src/backend/optimizer/plan/createplan.c |   1 +
 src/backend/optimizer/plan/planner.c    |  54 ++++++
 src/backend/optimizer/util/pathnode.c   |  19 +-
 src/backend/optimizer/util/plancat.c    |   3 +
 src/backend/optimizer/util/relnode.c    |  43 ++++-
 src/include/nodes/pathnodes.h           |  82 ++++++++-
 src/include/optimizer/cost.h            |   4 +-
 src/include/optimizer/pathnode.h        |  11 +-
 src/include/optimizer/paths.h           |   9 +-
 14 files changed, 451 insertions(+), 97 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 349863fb194..07480282518 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -954,7 +954,7 @@ set_tablesample_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *
 		 bms_membership(root->all_query_rels) != BMS_SINGLETON) &&
 		!(GetTsmRoutine(rte->tablesample->tsmhandler)->repeatable_across_scans))
 	{
-		path = (Path *) create_material_path(rel, path);
+		path = (Path *) create_material_path(rel, path, true);
 	}
 
 	add_path(rel, path);
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 94077e6a006..04a17367e9a 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -301,6 +301,7 @@ cost_seqscan(Path *path, PlannerInfo *root,
 	double		spc_seq_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = PGS_SEQSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -353,8 +354,11 @@ cost_seqscan(Path *path, PlannerInfo *root,
 		 */
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	path->disabled_nodes = enable_seqscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
 }
@@ -380,6 +384,7 @@ cost_samplescan(Path *path, PlannerInfo *root,
 				spc_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations with tablesample clauses */
 	Assert(baserel->relid > 0);
@@ -427,7 +432,11 @@ cost_samplescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -466,7 +475,8 @@ cost_gather(GatherPath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows;
 
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost;
 	path->path.total_cost = (startup_cost + run_cost);
 }
@@ -532,8 +542,8 @@ cost_gather_merge(GatherMergePath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows * 1.05;
 
-	path->path.disabled_nodes = input_disabled_nodes
-		+ (enable_gathermerge ? 0 : 1);
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER_MERGE) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost + input_startup_cost;
 	path->path.total_cost = (startup_cost + run_cost + input_total_cost);
 }
@@ -583,6 +593,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	double		pages_fetched;
 	double		rand_heap_pages;
 	double		index_pages;
+	uint64		enable_mask;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo) &&
@@ -614,8 +625,11 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 											  path->indexclauses);
 	}
 
-	/* we don't need to check enable_indexonlyscan; indxpath.c does that */
-	path->path.disabled_nodes = enable_indexscan ? 0 : 1;
+	/* is this scan type disabled? */
+	enable_mask = (indexonly ? PGS_INDEXONLYSCAN : PGS_INDEXSCAN)
+		| (path->path.parallel_workers == 0 ? PGS_CONSIDER_NONPARTIAL : 0);
+	path->path.disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 
 	/*
 	 * Call index-access-method-specific code to estimate the processing cost
@@ -1036,6 +1050,7 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	double		spc_seq_page_cost,
 				spc_random_page_cost;
 	double		T;
+	uint64		enable_mask = PGS_BITMAPSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo));
@@ -1101,6 +1116,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 
 	run_cost += cpu_run_cost;
@@ -1109,7 +1126,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = enable_bitmapscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1266,6 +1284,7 @@ cost_tidscan(Path *path, PlannerInfo *root,
 	double		ntuples;
 	ListCell   *l;
 	double		spc_random_page_cost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1287,10 +1306,10 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 		/*
 		 * We must use a TID scan for CurrentOfExpr; in any other case, we
-		 * should be generating a TID scan only if enable_tidscan=true. Also,
-		 * if CurrentOfExpr is the qual, there should be only one.
+		 * should be generating a TID scan only if TID scans are allowed.
+		 * Also, if CurrentOfExpr is the qual, there should be only one.
 		 */
-		Assert(enable_tidscan || IsA(qual, CurrentOfExpr));
+		Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0 || IsA(qual, CurrentOfExpr));
 		Assert(list_length(tidquals) == 1 || !IsA(qual, CurrentOfExpr));
 
 		if (IsA(qual, ScalarArrayOpExpr))
@@ -1342,10 +1361,14 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 	/*
 	 * There are assertions above verifying that we only reach this function
-	 * either when enable_tidscan=true or when the TID scan is the only legal
-	 * path, so it's safe to set disabled_nodes to zero here.
+	 * either when baserel->pgs_mask includes PGS_TIDSCAN or when the TID scan
+	 * is the only legal path, so we only need to consider the effects of
+	 * PGS_CONSIDER_NONPARTIAL here.
 	 */
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1375,6 +1398,7 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	double		nseqpages;
 	double		spc_random_page_cost;
 	double		spc_seq_page_cost;
+	uint64		enable_mask = PGS_TIDSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1438,8 +1462,15 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	/* we should not generate this path type when enable_tidscan=false */
-	Assert(enable_tidscan);
+	/*
+	 * We should not generate this path type when PGS_TIDSCAN is unset, but we
+	 * might need to disable this path due to PGS_CONSIDER_NONPARTIAL.
+	 */
+	Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0);
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->disabled_nodes = 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
@@ -1463,6 +1494,7 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	List	   *qpquals;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are subqueries */
 	Assert(baserel->relid > 0);
@@ -1493,7 +1525,10 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	 * SubqueryScan node, plus cpu_tuple_cost to account for selection and
 	 * projection overhead.
 	 */
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	if (path->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ (((baserel->pgs_mask & enable_mask) != enable_mask) ? 1 : 0);
 	path->path.startup_cost = path->subpath->startup_cost;
 	path->path.total_cost = path->subpath->total_cost;
 
@@ -1544,6 +1579,7 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1584,7 +1620,10 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1606,6 +1645,7 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1641,7 +1681,10 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1661,6 +1704,7 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are values lists */
 	Assert(baserel->relid > 0);
@@ -1689,7 +1733,10 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1712,6 +1759,7 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are CTEs */
 	Assert(baserel->relid > 0);
@@ -1737,7 +1785,10 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1754,6 +1805,7 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are Tuplestores */
 	Assert(baserel->relid > 0);
@@ -1775,7 +1827,10 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	cpu_per_tuple += cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1792,6 +1847,7 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to RTE_RESULT base relations */
 	Assert(baserel->relid > 0);
@@ -1810,7 +1866,10 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1828,6 +1887,7 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	Cost		startup_cost;
 	Cost		total_cost;
 	double		total_rows;
+	uint64		enable_mask = 0;
 
 	/* We probably have decent estimates for the non-recursive term */
 	startup_cost = nrterm->startup_cost;
@@ -1850,7 +1910,10 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	 */
 	total_cost += cpu_tuple_cost * total_rows;
 
-	runion->disabled_nodes = nrterm->disabled_nodes + rterm->disabled_nodes;
+	if (runion->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	runion->disabled_nodes =
+		(runion->parent->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	runion->startup_cost = startup_cost;
 	runion->total_cost = total_cost;
 	runion->rows = total_rows;
@@ -2120,7 +2183,11 @@ cost_incremental_sort(Path *path,
 
 	path->rows = input_tuples;
 
-	/* should not generate these paths when enable_incremental_sort=false */
+	/*
+	 * We should not generate these paths when enable_incremental_sort=false.
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	Assert(enable_incremental_sort);
 	path->disabled_nodes = input_disabled_nodes;
 
@@ -2158,6 +2225,10 @@ cost_sort(Path *path, PlannerInfo *root,
 
 	startup_cost += input_cost;
 
+	/*
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	path->rows = tuples;
 	path->disabled_nodes = input_disabled_nodes + (enable_sort ? 0 : 1);
 	path->startup_cost = startup_cost;
@@ -2249,9 +2320,15 @@ append_nonpartial_cost(List *subpaths, int numpaths, int parallel_workers)
 void
 cost_append(AppendPath *apath, PlannerInfo *root)
 {
+	RelOptInfo *rel = apath->path.parent;
 	ListCell   *l;
+	uint64		enable_mask = PGS_APPEND;
+
+	if (apath->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	apath->path.disabled_nodes = 0;
+	apath->path.disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	apath->path.startup_cost = 0;
 	apath->path.total_cost = 0;
 	apath->path.rows = 0;
@@ -2461,11 +2538,16 @@ cost_merge_append(Path *path, PlannerInfo *root,
 				  Cost input_startup_cost, Cost input_total_cost,
 				  double tuples)
 {
+	RelOptInfo *rel = path->parent;
 	Cost		startup_cost = 0;
 	Cost		run_cost = 0;
 	Cost		comparison_cost;
 	double		N;
 	double		logN;
+	uint64		enable_mask = PGS_MERGE_APPEND;
+
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/*
 	 * Avoid log(0)...
@@ -2488,7 +2570,9 @@ cost_merge_append(Path *path, PlannerInfo *root,
 	 */
 	run_cost += cpu_tuple_cost * APPEND_CPU_COST_MULTIPLIER * tuples;
 
-	path->disabled_nodes = input_disabled_nodes;
+	path->disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
+	path->disabled_nodes += input_disabled_nodes;
 	path->startup_cost = startup_cost + input_startup_cost;
 	path->total_cost = startup_cost + run_cost + input_total_cost;
 }
@@ -2507,7 +2591,7 @@ cost_merge_append(Path *path, PlannerInfo *root,
  */
 void
 cost_material(Path *path,
-			  int input_disabled_nodes,
+			  bool enabled, int input_disabled_nodes,
 			  Cost input_startup_cost, Cost input_total_cost,
 			  double tuples, int width)
 {
@@ -2516,6 +2600,10 @@ cost_material(Path *path,
 	double		nbytes = relation_byte_size(tuples, width);
 	double		work_mem_bytes = work_mem * (Size) 1024;
 
+	if (path->parallel_workers == 0 &&
+		(path->parent->pgs_mask & PGS_CONSIDER_NONPARTIAL) == 0)
+		enabled = false;
+
 	path->rows = tuples;
 
 	/*
@@ -2545,7 +2633,7 @@ cost_material(Path *path,
 		run_cost += seq_page_cost * npages;
 	}
 
-	path->disabled_nodes = input_disabled_nodes + (enable_material ? 0 : 1);
+	path->disabled_nodes = input_disabled_nodes + (enabled ? 0 : 1);
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -3297,7 +3385,7 @@ cost_group(Path *path, PlannerInfo *root,
  */
 void
 initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
-					  JoinType jointype,
+					  JoinType jointype, uint64 enable_mask,
 					  Path *outer_path, Path *inner_path,
 					  JoinPathExtraData *extra)
 {
@@ -3311,7 +3399,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Cost		inner_rescan_run_cost;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_nestloop ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
@@ -3711,7 +3799,19 @@ initial_cost_mergejoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Assert(outerstartsel <= outerendsel);
 	Assert(innerstartsel <= innerendsel);
 
-	disabled_nodes = enable_mergejoin ? 0 : 1;
+	/*
+	 * We don't decide whether to materialize the inner path until we get to
+	 * final_cost_mergejoin(), so we don't know whether to check the pgs_mask
+	 * again PGS_MERGEJOIN_PLAIN or PGS_MERGEJOIN_MATERIALIZE. Instead, we
+	 * just account for any child nodes here and assume that this node is not
+	 * itslef disabled; we can sort out the details in final_cost_mergejoin().
+	 *
+	 * (We could be more precise here by setting disabled_nodes to 1 at this
+	 * stage if both PGS_MERGEJOIN_PLAIN and PGS_MERGEJOIN_MATERIALIZE are
+	 * disabled, but that seems to against the idea of making this function
+	 * produce a quick, optimistic approximation of the final cost.)
+	 */
+	disabled_nodes = 0;
 
 	/* cost of source data */
 
@@ -3890,9 +3990,7 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	double		mergejointuples,
 				rescannedtuples;
 	double		rescanratio;
-
-	/* Set the number of disabled nodes. */
-	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+	uint64		enable_mask = 0;
 
 	/* Protect some assumptions below that rowcounts aren't zero */
 	if (inner_path_rows <= 0)
@@ -4022,16 +4120,20 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 		path->materialize_inner = false;
 
 	/*
-	 * Prefer materializing if it looks cheaper, unless the user has asked to
-	 * suppress materialization.
+	 * If merge joins with materialization are enabled, then choose
+	 * materialization if either (a) it looks cheaper or (b) merge joins
+	 * without materialization are disabled.
 	 */
-	else if (enable_material && mat_inner_cost < bare_inner_cost)
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 (mat_inner_cost < bare_inner_cost ||
+			  (extra->pgs_mask & PGS_MERGEJOIN_PLAIN) == 0))
 		path->materialize_inner = true;
 
 	/*
-	 * Even if materializing doesn't look cheaper, we *must* do it if the
-	 * inner path is to be used directly (without sorting) and it doesn't
-	 * support mark/restore.
+	 * Regardless of what plan shapes are enabled and what the costs seem to
+	 * be, we *must* materialize it if the inner path is to be used directly
+	 * (without sorting) and it doesn't support mark/restore. Planner failure
+	 * is not an option!
 	 *
 	 * Since the inner side must be ordered, and only Sorts and IndexScans can
 	 * create order to begin with, and they both support mark/restore, you
@@ -4039,10 +4141,6 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * merge joins can *preserve* the order of their inputs, so they can be
 	 * selected as the input of a mergejoin, and they don't support
 	 * mark/restore at present.
-	 *
-	 * We don't test the value of enable_material here, because
-	 * materialization is required for correctness in this case, and turning
-	 * it off does not entitle us to deliver an invalid plan.
 	 */
 	else if (innersortkeys == NIL &&
 			 !ExecSupportsMarkRestore(inner_path))
@@ -4056,10 +4154,11 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * though.
 	 *
 	 * Since materialization is a performance optimization in this case,
-	 * rather than necessary for correctness, we skip it if enable_material is
-	 * off.
+	 * rather than necessary for correctness, we skip it if materialization is
+	 * switched off.
 	 */
-	else if (enable_material && innersortkeys != NIL &&
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 innersortkeys != NIL &&
 			 relation_byte_size(inner_path_rows,
 								inner_path->pathtarget->width) >
 			 work_mem * (Size) 1024)
@@ -4067,11 +4166,29 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	else
 		path->materialize_inner = false;
 
-	/* Charge the right incremental cost for the chosen case */
+	/* Get the number of disabled nodes, not yet including this one. */
+	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+
+	/*
+	 * Charge the right incremental cost for the chosen case, and update
+	 * enable_mask as appropriate.
+	 */
 	if (path->materialize_inner)
+	{
 		run_cost += mat_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
 	else
+	{
 		run_cost += bare_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_PLAIN;
+	}
+
+	/* Incremental count of disabled nodes if this node is disabled. */
+	if (path->jpath.path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	if ((extra->pgs_mask & enable_mask) != enable_mask)
+		++path->jpath.path.disabled_nodes;
 
 	/* CPU costs */
 
@@ -4209,9 +4326,13 @@ initial_cost_hashjoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	int			numbatches;
 	int			num_skew_mcvs;
 	size_t		space_allowed;	/* unused */
+	uint64		enable_mask = PGS_HASHJOIN;
+
+	if (outer_path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_hashjoin ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index edc6d2ac1d3..a701c847cb5 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -2233,8 +2233,8 @@ check_index_only(RelOptInfo *rel, IndexOptInfo *index)
 	ListCell   *lc;
 	int			i;
 
-	/* Index-only scans must be enabled */
-	if (!enable_indexonlyscan)
+	/* If we're not allowed to consider index-only scans, give up now */
+	if ((rel->pgs_mask & PGS_CONSIDER_INDEXONLY) == 0)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index 3b9407eb2eb..4bf7af51f2f 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -29,8 +29,9 @@
 #include "utils/lsyscache.h"
 #include "utils/typcache.h"
 
-/* Hook for plugins to get control in add_paths_to_joinrel() */
+/* Hooks for plugins to get control in add_paths_to_joinrel() */
 set_join_pathlist_hook_type set_join_pathlist_hook = NULL;
+join_path_setup_hook_type join_path_setup_hook = NULL;
 
 /*
  * Paths parameterized by a parent rel can be considered to be parameterized
@@ -151,6 +152,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	extra.mergeclause_list = NIL;
 	extra.sjinfo = sjinfo;
 	extra.param_source_rels = NULL;
+	extra.pgs_mask = joinrel->pgs_mask;
 
 	/*
 	 * See if the inner relation is provably unique for this outer rel.
@@ -207,13 +209,38 @@ add_paths_to_joinrel(PlannerInfo *root,
 	if (jointype == JOIN_UNIQUE_OUTER || jointype == JOIN_UNIQUE_INNER)
 		jointype = JOIN_INNER;
 
+	/*
+	 * Give extensions a chance to take control. In particular, an extension
+	 * might want to modify extra.pgs_mask. It's possible to override pgs_mask
+	 * on a query-wide basis using join_search_hook, or for a particular
+	 * relation using joinrel_setup_hook, but extensions that want to provide
+	 * different advice for the same joinrel based on the choice of innerrel
+	 * and outerrel will need to use this hook.
+	 *
+	 * A very simple way for an extension to use this hook is to set
+	 * extra.pgs_mask = 0, if it simply doesn't want any of the paths
+	 * generated by this call to add_paths_to_joinrel() to be selected. An
+	 * extension could use this technique to constrain the join order, since
+	 * it could thereby arrange to reject all paths from join orders that it
+	 * does not like. An extension can also selectively clear bits from
+	 * extra.pgs_mask to rule out specific techniques for specific joins, or
+	 * even replace the mask entirely.
+	 *
+	 * NB: Below this point, this function should be careful to reference
+	 * extra.pgs_mask rather than rel->pgs_mask to avoid disregarding any
+	 * changes made by the hook we're about to call.
+	 */
+	if (join_path_setup_hook)
+		join_path_setup_hook(root, joinrel, outerrel, innerrel,
+							 jointype, &extra);
+
 	/*
 	 * Find potential mergejoin clauses.  We can skip this if we are not
 	 * interested in doing a mergejoin.  However, mergejoin may be our only
-	 * way of implementing a full outer join, so override enable_mergejoin if
-	 * it's a full join.
+	 * way of implementing a full outer join, so in that case we don't care
+	 * whether mergejoins are disabled.
 	 */
-	if (enable_mergejoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_MERGEJOIN_ANY) != 0 || jointype == JOIN_FULL)
 		extra.mergeclause_list = select_mergejoin_clauses(root,
 														  joinrel,
 														  outerrel,
@@ -321,10 +348,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 
 	/*
 	 * 4. Consider paths where both outer and inner relations must be hashed
-	 * before being joined.  As above, disregard enable_hashjoin for full
-	 * joins, because there may be no other alternative.
+	 * before being joined.  As above, when it's a full join, we must try this
+	 * even when the path type is disabled, because it may be our only option.
 	 */
-	if (enable_hashjoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_HASHJOIN) != 0 || jointype == JOIN_FULL)
 		hash_inner_and_outer(root, joinrel, outerrel, innerrel,
 							 jointype, &extra);
 
@@ -333,7 +360,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	 * to the same server and assigned to the same user to check access
 	 * permissions as, give the FDW a chance to push down joins.
 	 */
-	if (joinrel->fdwroutine &&
+	if ((extra.pgs_mask & PGS_FOREIGNJOIN) != 0 && joinrel->fdwroutine &&
 		joinrel->fdwroutine->GetForeignJoinPaths)
 		joinrel->fdwroutine->GetForeignJoinPaths(root, joinrel,
 												 outerrel, innerrel,
@@ -342,8 +369,13 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * 6. Finally, give extensions a chance to manipulate the path list.  They
 	 * could add new paths (such as CustomPaths) by calling add_path(), or
-	 * add_partial_path() if parallel aware.  They could also delete or modify
-	 * paths added by the core code.
+	 * add_partial_path() if parallel aware.
+	 *
+	 * In theory, extensions could also use this hook to delete or modify
+	 * paths added by the core code, but in practice this is difficult to make
+	 * work, since it's too late to get back any paths that have already been
+	 * discarded by add_path() or add_partial_path(). If you're trying to
+	 * suppress paths, consider using join_path_setup_hook instead.
 	 */
 	if (set_join_pathlist_hook)
 		set_join_pathlist_hook(root, joinrel, outerrel, innerrel,
@@ -690,7 +722,7 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel,
 	List	   *ph_lateral_vars;
 
 	/* Obviously not if it's disabled */
-	if (!enable_memoize)
+	if ((extra->pgs_mask & PGS_NESTLOOP_MEMOIZE) == 0)
 		return NULL;
 
 	/*
@@ -845,6 +877,7 @@ try_nestloop_path(PlannerInfo *root,
 				  Path *inner_path,
 				  List *pathkeys,
 				  JoinType jointype,
+				  uint64 nestloop_subtype,
 				  JoinPathExtraData *extra)
 {
 	Relids		required_outer;
@@ -927,6 +960,7 @@ try_nestloop_path(PlannerInfo *root,
 	 * methodology worthwhile.
 	 */
 	initial_cost_nestloop(root, &workspace, jointype,
+						  nestloop_subtype | PGS_CONSIDER_NONPARTIAL,
 						  outer_path, inner_path, extra);
 
 	if (add_path_precheck(joinrel, workspace.disabled_nodes,
@@ -964,6 +998,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 						  Path *inner_path,
 						  List *pathkeys,
 						  JoinType jointype,
+						  uint64 nestloop_subtype,
 						  JoinPathExtraData *extra)
 {
 	JoinCostWorkspace workspace;
@@ -1011,7 +1046,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 	 * Before creating a path, get a quick lower bound on what it is likely to
 	 * cost.  Bail out right away if it looks terrible.
 	 */
-	initial_cost_nestloop(root, &workspace, jointype,
+	initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype,
 						  outer_path, inner_path, extra);
 	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
 								   workspace.total_cost, pathkeys))
@@ -1859,14 +1894,14 @@ match_unsorted_outer(PlannerInfo *root,
 	if (nestjoinOK)
 	{
 		/*
-		 * Consider materializing the cheapest inner path, unless
-		 * enable_material is off or the path in question materializes its
-		 * output anyway.
+		 * Consider materializing the cheapest inner path, unless that is
+		 * disabled or the path in question materializes its output anyway.
 		 */
-		if (enable_material && inner_cheapest_total != NULL &&
+		if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+			inner_cheapest_total != NULL &&
 			!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 			matpath = (Path *)
-				create_material_path(innerrel, inner_cheapest_total);
+				create_material_path(innerrel, inner_cheapest_total, true);
 	}
 
 	foreach(lc1, outerrel->pathlist)
@@ -1909,6 +1944,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  innerpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_PLAIN,
 								  extra);
 
 				/*
@@ -1925,6 +1961,7 @@ match_unsorted_outer(PlannerInfo *root,
 									  mpath,
 									  merge_pathkeys,
 									  jointype,
+									  PGS_NESTLOOP_MEMOIZE,
 									  extra);
 			}
 
@@ -1936,6 +1973,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  matpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_MATERIALIZE,
 								  extra);
 		}
 
@@ -2052,16 +2090,17 @@ consider_parallel_nestloop(PlannerInfo *root,
 
 	/*
 	 * Consider materializing the cheapest inner path, unless: 1)
-	 * enable_material is off, 2) the cheapest inner path is not
+	 * materialization is disabled here, 2) the cheapest inner path is not
 	 * parallel-safe, 3) the cheapest inner path is parameterized by the outer
 	 * rel, or 4) the cheapest inner path materializes its output anyway.
 	 */
-	if (enable_material && inner_cheapest_total->parallel_safe &&
+	if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+		inner_cheapest_total->parallel_safe &&
 		!PATH_PARAM_BY_REL(inner_cheapest_total, outerrel) &&
 		!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 	{
 		matpath = (Path *)
-			create_material_path(innerrel, inner_cheapest_total);
+			create_material_path(innerrel, inner_cheapest_total, true);
 		Assert(matpath->parallel_safe);
 	}
 
@@ -2091,7 +2130,8 @@ consider_parallel_nestloop(PlannerInfo *root,
 				continue;
 
 			try_partial_nestloop_path(root, joinrel, outerpath, innerpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_PLAIN, extra);
 
 			/*
 			 * Try generating a memoize path and see if that makes the nested
@@ -2102,13 +2142,15 @@ consider_parallel_nestloop(PlannerInfo *root,
 									 extra);
 			if (mpath != NULL)
 				try_partial_nestloop_path(root, joinrel, outerpath, mpath,
-										  pathkeys, jointype, extra);
+										  pathkeys, jointype,
+										  PGS_NESTLOOP_MEMOIZE, extra);
 		}
 
 		/* Also consider materialized form of the cheapest inner path */
 		if (matpath != NULL)
 			try_partial_nestloop_path(root, joinrel, outerpath, matpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_MATERIALIZE, extra);
 	}
 }
 
diff --git a/src/backend/optimizer/path/tidpath.c b/src/backend/optimizer/path/tidpath.c
index 2bfb338b81c..639a0d3cadb 100644
--- a/src/backend/optimizer/path/tidpath.c
+++ b/src/backend/optimizer/path/tidpath.c
@@ -500,18 +500,19 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	List	   *tidquals;
 	List	   *tidrangequals;
 	bool		isCurrentOf;
+	bool		enabled = (rel->pgs_mask & PGS_TIDSCAN) != 0;
 
 	/*
 	 * If any suitable quals exist in the rel's baserestrict list, generate a
 	 * plain (unparameterized) TidPath with them.
 	 *
-	 * We skip this when enable_tidscan = false, except when the qual is
+	 * We skip this when TID scans are disabled, except when the qual is
 	 * CurrentOfExpr. In that case, a TID scan is the only correct path.
 	 */
 	tidquals = TidQualFromRestrictInfoList(root, rel->baserestrictinfo, rel,
 										   &isCurrentOf);
 
-	if (tidquals != NIL && (enable_tidscan || isCurrentOf))
+	if (tidquals != NIL && (enabled || isCurrentOf))
 	{
 		/*
 		 * This path uses no join clauses, but it could still have required
@@ -533,7 +534,7 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	}
 
 	/* Skip the rest if TID scans are disabled. */
-	if (!enable_tidscan)
+	if (!enabled)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 655c1097812..7ead6dad932 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -6534,6 +6534,7 @@ materialize_finished_plan(Plan *subplan)
 
 	/* Set cost data */
 	cost_material(&matpath,
+				  enable_material,
 				  subplan->disabled_nodes,
 				  subplan->startup_cost,
 				  subplan->total_cost,
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 8b1ab847f39..e2683b2481f 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -462,6 +462,53 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 		tuple_fraction = 0.0;
 	}
 
+	/*
+	 * Compute the initial path generation strategy mask.
+	 *
+	 * Some strategies, such as PGS_FOREIGNJOIN, have no corresponding enable_*
+	 * GUC, and so the corresponding bits are always set in the default
+	 * strategy mask.
+	 *
+	 * It may seem surprising that enable_indexscan sets both PGS_INDEXSCAN
+	 * and PGS_INDEXONLYSCAN. However, the historical behavior of this GUC
+	 * corresponds to this exactly: enable_indexscan=off disables both
+	 * index-scan and index-only scan paths, whereas enable_indexonlyscan=off
+	 * converts the index-only scan paths that we would have considered into
+	 * index scan paths.
+	 */
+	glob->default_pgs_mask = PGS_APPEND | PGS_MERGE_APPEND | PGS_FOREIGNJOIN |
+		PGS_GATHER | PGS_CONSIDER_NONPARTIAL;
+	if (enable_tidscan)
+		glob->default_pgs_mask |= PGS_TIDSCAN;
+	if (enable_seqscan)
+		glob->default_pgs_mask |= PGS_SEQSCAN;
+	if (enable_indexscan)
+		glob->default_pgs_mask |= PGS_INDEXSCAN | PGS_INDEXONLYSCAN;
+	if (enable_indexonlyscan)
+		glob->default_pgs_mask |= PGS_CONSIDER_INDEXONLY;
+	if (enable_bitmapscan)
+		glob->default_pgs_mask |= PGS_BITMAPSCAN;
+	if (enable_mergejoin)
+	{
+		glob->default_pgs_mask |= PGS_MERGEJOIN_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
+	if (enable_nestloop)
+	{
+		glob->default_pgs_mask |= PGS_NESTLOOP_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MATERIALIZE;
+		if (enable_memoize)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MEMOIZE;
+	}
+	if (enable_hashjoin)
+		glob->default_pgs_mask |= PGS_HASHJOIN;
+	if (enable_gathermerge)
+		glob->default_pgs_mask |= PGS_GATHER_MERGE;
+	if (enable_partitionwise_join)
+		glob->default_pgs_mask |= PGS_CONSIDER_PARTITIONWISE;
+
 	/* Allow plugins to take control after we've initialized "glob" */
 	if (planner_setup_hook)
 		(*planner_setup_hook) (glob, parse, query_string, &tuple_fraction, es);
@@ -3954,6 +4001,9 @@ make_grouping_rel(PlannerInfo *root, RelOptInfo *input_rel,
 		is_parallel_safe(root, (Node *) havingQual))
 		grouped_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed */
+	grouped_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the grouped rel.
 	 */
@@ -5348,6 +5398,9 @@ create_ordered_paths(PlannerInfo *root,
 	if (input_rel->consider_parallel && target_parallel_safe)
 		ordered_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed. */
+	ordered_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the ordered_rel.
 	 */
@@ -7428,6 +7481,7 @@ create_partial_grouping_paths(PlannerInfo *root,
 											grouped_rel->relids);
 	partially_grouped_rel->consider_parallel =
 		grouped_rel->consider_parallel;
+	partially_grouped_rel->pgs_mask = grouped_rel->pgs_mask;
 	partially_grouped_rel->reloptkind = grouped_rel->reloptkind;
 	partially_grouped_rel->serverid = grouped_rel->serverid;
 	partially_grouped_rel->userid = grouped_rel->userid;
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 0c0098691a2..315fe3e1f0d 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1657,7 +1657,7 @@ create_group_result_path(PlannerInfo *root, RelOptInfo *rel,
  *	  pathnode.
  */
 MaterialPath *
-create_material_path(RelOptInfo *rel, Path *subpath)
+create_material_path(RelOptInfo *rel, Path *subpath, bool enabled)
 {
 	MaterialPath *pathnode = makeNode(MaterialPath);
 
@@ -1676,6 +1676,7 @@ create_material_path(RelOptInfo *rel, Path *subpath)
 	pathnode->subpath = subpath;
 
 	cost_material(&pathnode->path,
+				  enabled,
 				  subpath->disabled_nodes,
 				  subpath->startup_cost,
 				  subpath->total_cost,
@@ -1728,8 +1729,15 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath,
 	pathnode->est_unique_keys = 0.0;
 	pathnode->est_hit_ratio = 0.0;
 
-	/* we should not generate this path type when enable_memoize=false */
-	Assert(enable_memoize);
+	/*
+	 * We should not be asked to generate this path type when memoization is
+	 * disabled, so set our count of disabled nodes equal to the subpath's
+	 * count.
+	 *
+	 * It would be nice to also Assert that memoization is enabled, but the
+	 * value of enable_memoize is not controlling: what we would need to check
+	 * is that the JoinPathExtraData's pgs_mask included PGS_NESTLOOP_MEMOIZE.
+	 */
 	pathnode->path.disabled_nodes = subpath->disabled_nodes;
 
 	/*
@@ -3962,13 +3970,16 @@ reparameterize_path(PlannerInfo *root, Path *path,
 			{
 				MaterialPath *mpath = (MaterialPath *) path;
 				Path	   *spath = mpath->subpath;
+				bool		enabled;
 
 				spath = reparameterize_path(root, spath,
 											required_outer,
 											loop_count);
+				enabled =
+					(mpath->path.disabled_nodes <= spath->disabled_nodes);
 				if (spath == NULL)
 					return NULL;
-				return (Path *) create_material_path(rel, spath);
+				return (Path *) create_material_path(rel, spath, enabled);
 			}
 		case T_Memoize:
 			{
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index f4b7343dace..4aacde28165 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -557,6 +557,9 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 	 * Allow a plugin to editorialize on the info we obtained from the
 	 * catalogs.  Actions might include altering the assumed relation size,
 	 * removing an index, or adding a hypothetical index to the indexlist.
+	 *
+	 * An extension can also modify rel->pgs_mask here to control path
+	 * generation.
 	 */
 	if (get_relation_info_hook)
 		(*get_relation_info_hook) (root, relationObjectId, inhparent, rel);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 1158bc194c3..034d0c9c87a 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -47,6 +47,9 @@ typedef struct JoinHashEntry
 	RelOptInfo *join_rel;
 } JoinHashEntry;
 
+/* Hook for plugins to get control during joinrel setup */
+joinrel_setup_hook_type joinrel_setup_hook = NULL;
+
 static void build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 								RelOptInfo *input_rel,
 								SpecialJoinInfo *sjinfo,
@@ -225,6 +228,7 @@ build_simple_rel(PlannerInfo *root, int relid, RelOptInfo *parent)
 	rel->consider_startup = (root->tuple_fraction > 0);
 	rel->consider_param_startup = false;	/* might get changed later */
 	rel->consider_parallel = false; /* might get changed later */
+	rel->pgs_mask = root->glob->default_pgs_mask;
 	rel->reltarget = create_empty_pathtarget();
 	rel->pathlist = NIL;
 	rel->ppilist = NIL;
@@ -822,6 +826,7 @@ build_join_rel(PlannerInfo *root,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -934,10 +939,6 @@ build_join_rel(PlannerInfo *root,
 	 */
 	joinrel->has_eclass_joins = has_relevant_eclass_joinclause(root, joinrel);
 
-	/* Store the partition information. */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/*
 	 * Set estimates of the joinrel's size.
 	 */
@@ -963,6 +964,18 @@ build_join_rel(PlannerInfo *root,
 		is_parallel_safe(root, (Node *) joinrel->reltarget->exprs))
 		joinrel->consider_parallel = true;
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Store the partition information. */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* Add the joinrel to the PlannerInfo. */
 	add_join_rel(root, joinrel);
 
@@ -1019,6 +1032,7 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -1102,10 +1116,6 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	 */
 	joinrel->has_eclass_joins = parent_joinrel->has_eclass_joins;
 
-	/* Is the join between partitions itself partitioned? */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/* Child joinrel is parallel safe if parent is parallel safe. */
 	joinrel->consider_parallel = parent_joinrel->consider_parallel;
 
@@ -1113,6 +1123,20 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	set_joinrel_size_estimates(root, joinrel, outer_rel, inner_rel,
 							   sjinfo, restrictlist);
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel,
+	 * although the latter would be better done in the parent joinrel rather
+	 * than here.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Is the join between partitions itself partitioned? */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* We build the join only once. */
 	Assert(!find_join_rel(root, joinrel->relids));
 
@@ -1602,6 +1626,7 @@ fetch_upper_rel(PlannerInfo *root, UpperRelationKind kind, Relids relids)
 	upperrel = makeNode(RelOptInfo);
 	upperrel->reloptkind = RELOPT_UPPER_REL;
 	upperrel->relids = bms_copy(relids);
+	upperrel->pgs_mask = root->glob->default_pgs_mask;
 
 	/* cheap startup cost is interesting iff not all tuples to be retrieved */
 	upperrel->consider_startup = (root->tuple_fraction > 0);
@@ -2118,7 +2143,7 @@ build_joinrel_partition_info(PlannerInfo *root,
 	PartitionScheme part_scheme;
 
 	/* Nothing to do if partitionwise join technique is disabled. */
-	if (!enable_partitionwise_join)
+	if ((joinrel->pgs_mask & PGS_CONSIDER_PARTITIONWISE) == 0)
 	{
 		Assert(!IS_PARTITIONED_REL(joinrel));
 		return;
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 75a70489e5a..4746d3c43c4 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -22,6 +22,79 @@
 #include "nodes/parsenodes.h"
 #include "storage/block.h"
 
+/*
+ * Path generation strategies.
+ *
+ * These constants are used to specify the set of strategies that the planner
+ * should use, either for the query as a whole or for a specific baserel or
+ * joinrel. The various planner-related enable_* GUCs are used to set the
+ * PlannerGlobal's default_pgs_mask, and that in turn is used to set each
+ * RelOptInfo's pgs_mask. In both cases, extensions can use hooks to modify the
+ * default value.  Not every strategy listed here has a corresponding enable_*
+ * GUC; those that don't are always allowed unless disabled by an extension.
+ * Not all strategies are relevant for every RelOptInfo; e.g. PGS_SEQSCAN
+ * doesn't affect joinrels one way or the other.
+ *
+ * In most cases, disabling a path generation strategy merely means that any
+ * paths generated using that strategy are marked as disabled, but in some
+ * cases, path generation is skipped altogether. The latter strategy is only
+ * permissible when it can't result in planner failure -- for instance, we
+ * couldn't do this for sequential scans on a plain rel, because there might
+ * not be any other possible path. Nevertheless, the behaviors in each
+ * individual case are to some extent the result of historical accident,
+ * chosen to match the preexisting behaviors of the enable_* GUCs.
+ *
+ * In a few cases, we have more than one bit for the same strategy, controlling
+ * different aspects of the planner behavior. When PGS_CONSIDER_INDEXONLY is
+ * unset, we don't even consider index-only scans, and any such scans that
+ * would have been generated become index scans instead. On the other hand,
+ * unsetting PGS_INDEXSCAN or PGS_INDEXONLYSCAN causes generated paths of the
+ * corresponding types to be marked as disabled. Similarly, unsetting
+ * PGS_CONSIDER_PARTITIONWISE prevents any sort of thinking about partitionwise
+ * joins for the current rel, which incidentally will preclude higher-level
+ * joinrels from building parititonwise paths using paths taken from the
+ * current rel's children. On the other hand, unsetting PGS_APPEND or
+ * PGS_MERGE_APPEND will only arrange to disable paths of the corresponding
+ * types if they are generated at the level of the current rel.
+ *
+ * Finally, unsetting PGS_CONSIDER_NONPARTIAL disables all non-partial paths
+ * except those that use Gather or Gather Merge. In most other cases, a
+ * plugin can nudge the planner toward a particular strategy by disabling
+ * all of the others, but that doesn't work here: unsetting PGS_SEQSCAN,
+ * for instance, would disable both partial and non-partial sequential scans.
+ */
+#define PGS_SEQSCAN					0x00000001
+#define PGS_INDEXSCAN				0x00000002
+#define PGS_INDEXONLYSCAN			0x00000004
+#define PGS_BITMAPSCAN				0x00000008
+#define PGS_TIDSCAN					0x00000010
+#define PGS_FOREIGNJOIN				0x00000020
+#define PGS_MERGEJOIN_PLAIN			0x00000040
+#define PGS_MERGEJOIN_MATERIALIZE	0x00000080
+#define PGS_NESTLOOP_PLAIN			0x00000100
+#define PGS_NESTLOOP_MATERIALIZE	0x00000200
+#define PGS_NESTLOOP_MEMOIZE		0x00000400
+#define PGS_HASHJOIN				0x00000800
+#define PGS_APPEND					0x00001000
+#define PGS_MERGE_APPEND			0x00002000
+#define PGS_GATHER					0x00004000
+#define PGS_GATHER_MERGE			0x00008000
+#define PGS_CONSIDER_INDEXONLY		0x00010000
+#define PGS_CONSIDER_PARTITIONWISE	0x00020000
+#define PGS_CONSIDER_NONPARTIAL		0x00040000
+
+/*
+ * Convenience macros for useful combination of the bits defined above.
+ */
+#define PGS_SCAN_ANY		\
+	(PGS_SEQSCAN | PGS_INDEXSCAN | PGS_INDEXONLYSCAN | PGS_BITMAPSCAN | \
+	 PGS_TIDSCAN)
+#define PGS_MERGEJOIN_ANY	\
+	(PGS_MERGEJOIN_PLAIN | PGS_MERGEJOIN_MATERIALIZE)
+#define PGS_NESTLOOP_ANY	\
+	(PGS_NESTLOOP_PLAIN | PGS_NESTLOOP_MATERIALIZE | PGS_NESTLOOP_MEMOIZE)
+#define PGS_JOIN_ANY		\
+	(PGS_FOREIGNJOIN | PGS_MERGEJOIN_ANY | PGS_NESTLOOP_ANY | PGS_HASHJOIN)
 
 /*
  * Relids
@@ -186,6 +259,9 @@ typedef struct PlannerGlobal
 	/* worst PROPARALLEL hazard level */
 	char		maxParallelHazard;
 
+	/* mask of allowed path generation strategies */
+	uint64		default_pgs_mask;
+
 	/* partition descriptors */
 	PartitionDirectory partition_directory pg_node_attr(read_write_ignore);
 
@@ -939,7 +1015,7 @@ typedef struct RelOptInfo
 	Cardinality rows;
 
 	/*
-	 * per-relation planner control flags
+	 * per-relation planner control
 	 */
 	/* keep cheap-startup-cost paths? */
 	bool		consider_startup;
@@ -947,6 +1023,8 @@ typedef struct RelOptInfo
 	bool		consider_param_startup;
 	/* consider parallel paths? */
 	bool		consider_parallel;
+	/* path generation strategy mask */
+	uint64		pgs_mask;
 
 	/*
 	 * default result targetlist for Paths scanning this relation; list of
@@ -3505,6 +3583,7 @@ typedef struct SemiAntiJoinFactors
  * sjinfo is extra info about special joins for selectivity estimation
  * semifactors is as shown above (only valid for SEMI/ANTI/inner_unique joins)
  * param_source_rels are OK targets for parameterization of result paths
+ * pgs_mask is a bitmask of PGS_* constants to limit the join strategy
  */
 typedef struct JoinPathExtraData
 {
@@ -3514,6 +3593,7 @@ typedef struct JoinPathExtraData
 	SpecialJoinInfo *sjinfo;
 	SemiAntiJoinFactors semifactors;
 	Relids		param_source_rels;
+	uint64		pgs_mask;
 } JoinPathExtraData;
 
 /*
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index b523bcda8f3..2d80462bece 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -125,7 +125,7 @@ extern void cost_merge_append(Path *path, PlannerInfo *root,
 							  Cost input_startup_cost, Cost input_total_cost,
 							  double tuples);
 extern void cost_material(Path *path,
-						  int input_disabled_nodes,
+						  bool enabled, int input_disabled_nodes,
 						  Cost input_startup_cost, Cost input_total_cost,
 						  double tuples, int width);
 extern void cost_agg(Path *path, PlannerInfo *root,
@@ -148,7 +148,7 @@ extern void cost_group(Path *path, PlannerInfo *root,
 					   double input_tuples);
 extern void initial_cost_nestloop(PlannerInfo *root,
 								  JoinCostWorkspace *workspace,
-								  JoinType jointype,
+								  JoinType jointype, uint64 enable_mask,
 								  Path *outer_path, Path *inner_path,
 								  JoinPathExtraData *extra);
 extern void final_cost_nestloop(PlannerInfo *root, NestPath *path,
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 4437248cb67..274cd41bab1 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -17,6 +17,14 @@
 #include "nodes/bitmapset.h"
 #include "nodes/pathnodes.h"
 
+/* Hook for plugins to get control during joinrel setup */
+typedef void (*joinrel_setup_hook_type) (PlannerInfo *root,
+										 RelOptInfo *joinrel,
+										 RelOptInfo *outer_rel,
+										 RelOptInfo *inner_rel,
+										 SpecialJoinInfo *sjinfo,
+										 List *restrictlist);
+extern PGDLLIMPORT joinrel_setup_hook_type joinrel_setup_hook;
 
 /*
  * prototypes for pathnode.c
@@ -84,7 +92,8 @@ extern GroupResultPath *create_group_result_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 PathTarget *target,
 												 List *havingqual);
-extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath);
+extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath,
+										  bool enabled);
 extern MemoizePath *create_memoize_path(PlannerInfo *root,
 										RelOptInfo *rel,
 										Path *subpath,
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index f6a62df0b43..61c1607f872 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -28,7 +28,14 @@ extern PGDLLIMPORT int min_parallel_table_scan_size;
 extern PGDLLIMPORT int min_parallel_index_scan_size;
 extern PGDLLIMPORT bool enable_group_by_reordering;
 
-/* Hook for plugins to get control in set_rel_pathlist() */
+/* Hooks for plugins to get control in set_rel_pathlist() */
+typedef void (*join_path_setup_hook_type) (PlannerInfo *root,
+										   RelOptInfo *joinrel,
+										   RelOptInfo *outerrel,
+										   RelOptInfo *innerrel,
+										   JoinType jointype,
+										   JoinPathExtraData *extra);
+extern PGDLLIMPORT join_path_setup_hook_type join_path_setup_hook;
 typedef void (*set_rel_pathlist_hook_type) (PlannerInfo *root,
 											RelOptInfo *rel,
 											Index rti,
-- 
2.39.3 (Apple Git-145)



  [application/octet-stream] v1-0003-Store-information-about-Append-node-consolidation.patch (27.0K, 3-v1-0003-Store-information-about-Append-node-consolidation.patch)
  download | inline diff:
From 8e37fb762b9df0bcd3262b6f8d3e798cb067ee5b Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:07 -0400
Subject: [PATCH v1 3/6] Store information about Append node consolidation in
 the final plan.

An extension (or core code) might want to reconstruct the planner's
decisions about whether and where to perform partitionwise joins from
the final plan. To do so, it must be possible to find all of the RTIs
of partitioned tables appearing in the plan. But when an AppendPath
or MergeAppendPath pulls up child paths from a subordinate AppendPath
or MergeAppendPath, the RTIs of the subordinate path do not appear
in the final plan, making this kind of reconstruction impossible.

To avoid this, propagate the RTI sets that would have been present
in the 'apprelids' field of the subordinate Append or MergeAppend
nodes that would have been created into the surviving Append or
MergeAppend node, using a new 'child_append_relid_sets' field for
that purpose. The value of this field is a list of Bitmapsets,
because each relation whose append-list was pulled up had its own
set of RTIs: just one, if it was a partitionwise scan, or more than
one, if it was a partitionwise join. Since our goal is to see where
partitionwise joins were done, it is essential to avoid losing the
information about how the RTIs were grouped in the pulled-up
relations.

This commit also updates pg_overexplain so that EXPLAIN (RANGE_TABLE)
will display the saved RTI sets.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 56 +++++++++++
 src/backend/optimizer/path/allpaths.c         | 98 +++++++++++++++----
 src/backend/optimizer/path/joinrels.c         |  2 +-
 src/backend/optimizer/plan/createplan.c       |  2 +
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/prep/prepunion.c        | 11 ++-
 src/backend/optimizer/util/pathnode.c         |  5 +
 src/include/nodes/pathnodes.h                 | 10 ++
 src/include/nodes/plannodes.h                 | 11 +++
 src/include/optimizer/pathnode.h              |  2 +
 11 files changed, 175 insertions(+), 27 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index ca9a23ea61f..a377fb2571d 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -104,6 +104,7 @@ $$);
                Parallel Safe: true
                Plan Node ID: 2
                Append RTIs: 1
+               Child Append RTIs: none
                ->  Seq Scan on brassica vegetables_1
                      Disabled Nodes: 0
                      Parallel Safe: true
@@ -142,7 +143,7 @@ $$);
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 3 4
-(53 rows)
+(54 rows)
 
 -- Test a different output format.
 SELECT explain_filter($$
@@ -197,6 +198,7 @@ $$);
                <extParam>none</extParam>                            +
                <allParam>none</allParam>                            +
                <Append-RTIs>1</Append-RTIs>                         +
+               <Child-Append-RTIs>none</Child-Append-RTIs>          +
                <Subplans-Removed>0</Subplans-Removed>               +
                <Plans>                                              +
                  <Plan>                                             +
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index fa907fa472e..6538ffcafb0 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -54,6 +54,8 @@ static void overexplain_alias(const char *qlabel, Alias *alias,
 							  ExplainState *es);
 static void overexplain_bitmapset(const char *qlabel, Bitmapset *bms,
 								  ExplainState *es);
+static void overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+									   ExplainState *es);
 static void overexplain_intlist(const char *qlabel, List *list,
 								ExplainState *es);
 
@@ -232,11 +234,17 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 				overexplain_bitmapset("Append RTIs",
 									  ((Append *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((Append *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_MergeAppend:
 				overexplain_bitmapset("Append RTIs",
 									  ((MergeAppend *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((MergeAppend *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_Result:
 
@@ -815,6 +823,54 @@ overexplain_bitmapset(const char *qlabel, Bitmapset *bms, ExplainState *es)
 	pfree(buf.data);
 }
 
+/*
+ * Emit a text property describing the contents of a list of bitmapsets.
+ * If a bitmapset contains exactly 1 member, we just print an integer;
+ * otherwise, we surround the list of members by parentheses.
+ *
+ * If there are no bitmapsets in the list, we print the word "none".
+ */
+static void
+overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+						   ExplainState *es)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+
+	foreach_node(Bitmapset, bms, bms_list)
+	{
+		if (bms_membership(bms) == BMS_SINGLETON)
+			appendStringInfo(&buf, " %d", bms_singleton_member(bms));
+		else
+		{
+			int			x = -1;
+			bool		first = true;
+
+			appendStringInfoString(&buf, " (");
+			while ((x = bms_next_member(bms, x)) >= 0)
+			{
+				if (first)
+					first = false;
+				else
+					appendStringInfoChar(&buf, ' ');
+				appendStringInfo(&buf, "%d", x);
+			}
+			appendStringInfoChar(&buf, ')');
+		}
+	}
+
+	if (buf.len == 0)
+	{
+		ExplainPropertyText(qlabel, "none", es);
+		return;
+	}
+
+	Assert(buf.data[0] == ' ');
+	ExplainPropertyText(qlabel, buf.data + 1, es);
+	pfree(buf.data);
+}
+
 /*
  * Emit a text property describing the contents of a list of integers, OIDs,
  * or XIDs -- either a space-separated list of integer members, or the word
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 9c6436eb72f..349863fb194 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -128,8 +128,10 @@ static Path *get_cheapest_parameterized_child_path(PlannerInfo *root,
 												   Relids required_outer);
 static void accumulate_append_subpath(Path *path,
 									  List **subpaths,
-									  List **special_subpaths);
-static Path *get_singleton_append_subpath(Path *path);
+									  List **special_subpaths,
+									  List **child_append_relid_sets);
+static Path *get_singleton_append_subpath(Path *path,
+										  List **child_append_relid_sets);
 static void set_dummy_rel_pathlist(RelOptInfo *rel);
 static void set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 								  Index rti, RangeTblEntry *rte);
@@ -1406,11 +1408,15 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 {
 	List	   *subpaths = NIL;
 	bool		subpaths_valid = true;
+	List	   *subpath_cars = NIL;
 	List	   *startup_subpaths = NIL;
 	bool		startup_subpaths_valid = true;
+	List	   *startup_subpath_cars = NIL;
 	List	   *partial_subpaths = NIL;
+	List	   *partial_subpath_cars = NIL;
 	List	   *pa_partial_subpaths = NIL;
 	List	   *pa_nonpartial_subpaths = NIL;
+	List	   *pa_subpath_cars = NIL;
 	bool		partial_subpaths_valid = true;
 	bool		pa_subpaths_valid;
 	List	   *all_child_pathkeys = NIL;
@@ -1443,7 +1449,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		if (childrel->pathlist != NIL &&
 			childrel->cheapest_total_path->param_info == NULL)
 			accumulate_append_subpath(childrel->cheapest_total_path,
-									  &subpaths, NULL);
+									  &subpaths, NULL, &subpath_cars);
 		else
 			subpaths_valid = false;
 
@@ -1472,7 +1478,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 			Assert(cheapest_path->param_info == NULL);
 			accumulate_append_subpath(cheapest_path,
 									  &startup_subpaths,
-									  NULL);
+									  NULL,
+									  &startup_subpath_cars);
 		}
 		else
 			startup_subpaths_valid = false;
@@ -1483,7 +1490,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		{
 			cheapest_partial_path = linitial(childrel->partial_pathlist);
 			accumulate_append_subpath(cheapest_partial_path,
-									  &partial_subpaths, NULL);
+									  &partial_subpaths, NULL,
+									  &partial_subpath_cars);
 		}
 		else
 			partial_subpaths_valid = false;
@@ -1512,7 +1520,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				Assert(cheapest_partial_path != NULL);
 				accumulate_append_subpath(cheapest_partial_path,
 										  &pa_partial_subpaths,
-										  &pa_nonpartial_subpaths);
+										  &pa_nonpartial_subpaths,
+										  &pa_subpath_cars);
 			}
 			else
 			{
@@ -1531,7 +1540,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				 */
 				accumulate_append_subpath(nppath,
 										  &pa_nonpartial_subpaths,
-										  NULL);
+										  NULL,
+										  &pa_subpath_cars);
 			}
 		}
 
@@ -1606,14 +1616,16 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 	 * if we have zero or one live subpath due to constraint exclusion.)
 	 */
 	if (subpaths_valid)
-		add_path(rel, (Path *) create_append_path(root, rel, subpaths, NIL,
+		add_path(rel, (Path *) create_append_path(root, rel, subpaths,
+												  NIL, subpath_cars,
 												  NIL, NULL, 0, false,
 												  -1));
 
 	/* build an AppendPath for the cheap startup paths, if valid */
 	if (startup_subpaths_valid)
 		add_path(rel, (Path *) create_append_path(root, rel, startup_subpaths,
-												  NIL, NIL, NULL, 0, false, -1));
+												  NIL, startup_subpath_cars,
+												  NIL, NULL, 0, false, -1));
 
 	/*
 	 * Consider an append of unordered, unparameterized partial paths.  Make
@@ -1654,6 +1666,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Generate a partial append path. */
 		appendpath = create_append_path(root, rel, NIL, partial_subpaths,
+										partial_subpath_cars,
 										NIL, NULL, parallel_workers,
 										enable_parallel_append,
 										-1);
@@ -1704,6 +1717,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		appendpath = create_append_path(root, rel, pa_nonpartial_subpaths,
 										pa_partial_subpaths,
+										pa_subpath_cars,
 										NIL, NULL, parallel_workers, true,
 										partial_rows);
 		add_partial_path(rel, (Path *) appendpath);
@@ -1737,6 +1751,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Select the child paths for an Append with this parameterization */
 		subpaths = NIL;
+		subpath_cars = NIL;
 		subpaths_valid = true;
 		foreach(lcr, live_childrels)
 		{
@@ -1759,12 +1774,13 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				subpaths_valid = false;
 				break;
 			}
-			accumulate_append_subpath(subpath, &subpaths, NULL);
+			accumulate_append_subpath(subpath, &subpaths, NULL,
+									  &subpath_cars);
 		}
 
 		if (subpaths_valid)
 			add_path(rel, (Path *)
-					 create_append_path(root, rel, subpaths, NIL,
+					 create_append_path(root, rel, subpaths, NIL, subpath_cars,
 										NIL, required_outer, 0, false,
 										-1));
 	}
@@ -1791,6 +1807,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				continue;
 
 			appendpath = create_append_path(root, rel, NIL, list_make1(path),
+											list_make1(rel->relids),
 											NIL, NULL,
 											path->parallel_workers, true,
 											partial_rows);
@@ -1872,8 +1889,11 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 	{
 		List	   *pathkeys = (List *) lfirst(lcp);
 		List	   *startup_subpaths = NIL;
+		List	   *startup_subpath_cars = NIL;
 		List	   *total_subpaths = NIL;
+		List	   *total_subpath_cars = NIL;
 		List	   *fractional_subpaths = NIL;
+		List	   *fractional_subpath_cars = NIL;
 		bool		startup_neq_total = false;
 		bool		match_partition_order;
 		bool		match_partition_order_desc;
@@ -2025,16 +2045,23 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * just a single subpath (and hence aren't doing anything
 				 * useful).
 				 */
-				cheapest_startup = get_singleton_append_subpath(cheapest_startup);
-				cheapest_total = get_singleton_append_subpath(cheapest_total);
+				cheapest_startup =
+					get_singleton_append_subpath(cheapest_startup,
+												 &startup_subpath_cars);
+				cheapest_total =
+					get_singleton_append_subpath(cheapest_total,
+												 &total_subpath_cars);
 
 				startup_subpaths = lappend(startup_subpaths, cheapest_startup);
 				total_subpaths = lappend(total_subpaths, cheapest_total);
 
 				if (cheapest_fractional)
 				{
-					cheapest_fractional = get_singleton_append_subpath(cheapest_fractional);
-					fractional_subpaths = lappend(fractional_subpaths, cheapest_fractional);
+					cheapest_fractional =
+						get_singleton_append_subpath(cheapest_fractional,
+													 &fractional_subpath_cars);
+					fractional_subpaths =
+						lappend(fractional_subpaths, cheapest_fractional);
 				}
 			}
 			else
@@ -2044,13 +2071,16 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * child paths for the MergeAppend.
 				 */
 				accumulate_append_subpath(cheapest_startup,
-										  &startup_subpaths, NULL);
+										  &startup_subpaths, NULL,
+										  &startup_subpath_cars);
 				accumulate_append_subpath(cheapest_total,
-										  &total_subpaths, NULL);
+										  &total_subpaths, NULL,
+										  &total_subpath_cars);
 
 				if (cheapest_fractional)
 					accumulate_append_subpath(cheapest_fractional,
-											  &fractional_subpaths, NULL);
+											  &fractional_subpaths, NULL,
+											  &fractional_subpath_cars);
 			}
 		}
 
@@ -2062,6 +2092,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 													  rel,
 													  startup_subpaths,
 													  NIL,
+													  startup_subpath_cars,
 													  pathkeys,
 													  NULL,
 													  0,
@@ -2072,6 +2103,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  total_subpaths,
 														  NIL,
+														  total_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2083,6 +2115,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  fractional_subpaths,
 														  NIL,
+														  fractional_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2095,12 +2128,14 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 			add_path(rel, (Path *) create_merge_append_path(root,
 															rel,
 															startup_subpaths,
+															startup_subpath_cars,
 															pathkeys,
 															NULL));
 			if (startup_neq_total)
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																total_subpaths,
+																total_subpath_cars,
 																pathkeys,
 																NULL));
 
@@ -2108,6 +2143,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																fractional_subpaths,
+																fractional_subpath_cars,
 																pathkeys,
 																NULL));
 		}
@@ -2210,7 +2246,8 @@ get_cheapest_parameterized_child_path(PlannerInfo *root, RelOptInfo *rel,
  * paths).
  */
 static void
-accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
+accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
+						  List **child_append_relid_sets)
 {
 	if (IsA(path, AppendPath))
 	{
@@ -2219,6 +2256,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		if (!apath->path.parallel_aware || apath->first_partial_path == 0)
 		{
 			*subpaths = list_concat(*subpaths, apath->subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 		else if (special_subpaths != NULL)
@@ -2233,6 +2272,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 												  apath->first_partial_path);
 			*special_subpaths = list_concat(*special_subpaths,
 											new_special_subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 	}
@@ -2241,6 +2282,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		*subpaths = list_concat(*subpaths, mpath->subpaths);
+		*child_append_relid_sets =
+			lappend(*child_append_relid_sets, path->parent->relids);
 		return;
 	}
 
@@ -2252,10 +2295,15 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
  *		Returns the single subpath of an Append/MergeAppend, or just
  *		return 'path' if it's not a single sub-path Append/MergeAppend.
  *
+ * As a side effect, whenever we return a single subpath rather than the
+ * original path, add the relid set for the original path to
+ * child_append_relid_sets, so that those relids don't entirely disappear
+ * from the final plan.
+ *
  * Note: 'path' must not be a parallel-aware path.
  */
 static Path *
-get_singleton_append_subpath(Path *path)
+get_singleton_append_subpath(Path *path, List **child_append_relid_sets)
 {
 	Assert(!path->parallel_aware);
 
@@ -2264,14 +2312,22 @@ get_singleton_append_subpath(Path *path)
 		AppendPath *apath = (AppendPath *) path;
 
 		if (list_length(apath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(apath->subpaths);
+		}
 	}
 	else if (IsA(path, MergeAppendPath))
 	{
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		if (list_length(mpath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(mpath->subpaths);
+		}
 	}
 
 	return path;
@@ -2300,7 +2356,7 @@ set_dummy_rel_pathlist(RelOptInfo *rel)
 	rel->partial_pathlist = NIL;
 
 	/* Set up the dummy path */
-	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
+	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL, NIL,
 											  NIL, rel->lateral_relids,
 											  0, false, -1));
 
diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 5d1fc3899da..c1ed0d3870f 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -1530,7 +1530,7 @@ mark_dummy_rel(RelOptInfo *rel)
 
 	/* Set up the dummy path */
 	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
-											  NIL, rel->lateral_relids,
+											  NIL, NIL, rel->lateral_relids,
 											  0, false, -1));
 
 	/* Set or update cheapest_total_path and related fields */
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 63fe6637155..655c1097812 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -1265,6 +1265,7 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags)
 	plan->plan.lefttree = NULL;
 	plan->plan.righttree = NULL;
 	plan->apprelids = rel->relids;
+	plan->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	if (pathkeys != NIL)
 	{
@@ -1477,6 +1478,7 @@ create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path,
 	plan->lefttree = NULL;
 	plan->righttree = NULL;
 	node->apprelids = rel->relids;
+	node->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	/*
 	 * Compute sort column info, and adjust MergeAppend's tlist as needed.
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 9d5262651e7..eb62794aecd 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4027,6 +4027,7 @@ create_degenerate_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
 							   paths,
 							   NIL,
 							   NIL,
+							   NIL,
 							   NULL,
 							   0,
 							   false,
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index 55665824179..b7c4c0686d0 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -839,7 +839,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 	 * union child.
 	 */
 	apath = (Path *) create_append_path(root, result_rel, cheapest_pathlist,
-										NIL, NIL, NULL, 0, false, -1);
+										NIL, NIL, NIL, NULL, 0, false, -1);
 
 	/*
 	 * Estimate number of groups.  For now we just assume the output is unique
@@ -885,7 +885,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 
 		papath = (Path *)
 			create_append_path(root, result_rel, NIL, partial_pathlist,
-							   NIL, NULL, parallel_workers,
+							   NIL, NIL, NULL, parallel_workers,
 							   enable_parallel_append, -1);
 		gpath = (Path *)
 			create_gather_path(root, result_rel, papath,
@@ -1011,6 +1011,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 			path = (Path *) create_merge_append_path(root,
 													 result_rel,
 													 ordered_pathlist,
+													 NIL,
 													 union_pathkeys,
 													 NULL);
 
@@ -1217,8 +1218,10 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root,
 				 * between the set op targetlist and the targetlist of the
 				 * left input.  The Append will be removed in setrefs.c.
 				 */
-				apath = (Path *) create_append_path(root, result_rel, list_make1(lpath),
-													NIL, NIL, NULL, 0, false, -1);
+				apath = (Path *) create_append_path(root, result_rel,
+													list_make1(lpath),
+													NIL, NIL, NIL, NULL, 0,
+													false, -1);
 
 				add_path(result_rel, apath);
 
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 44ac5312edd..0c0098691a2 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1299,6 +1299,7 @@ AppendPath *
 create_append_path(PlannerInfo *root,
 				   RelOptInfo *rel,
 				   List *subpaths, List *partial_subpaths,
+				   List *child_append_relid_sets,
 				   List *pathkeys, Relids required_outer,
 				   int parallel_workers, bool parallel_aware,
 				   double rows)
@@ -1308,6 +1309,7 @@ create_append_path(PlannerInfo *root,
 
 	Assert(!parallel_aware || parallel_workers > 0);
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_Append;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -1470,6 +1472,7 @@ MergeAppendPath *
 create_merge_append_path(PlannerInfo *root,
 						 RelOptInfo *rel,
 						 List *subpaths,
+						 List *child_append_relid_sets,
 						 List *pathkeys,
 						 Relids required_outer)
 {
@@ -1485,6 +1488,7 @@ create_merge_append_path(PlannerInfo *root,
 	 */
 	Assert(bms_is_empty(rel->lateral_relids) && bms_is_empty(required_outer));
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_MergeAppend;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -3948,6 +3952,7 @@ reparameterize_path(PlannerInfo *root, Path *path,
 				}
 				return (Path *)
 					create_append_path(root, rel, childpaths, partialpaths,
+									   apath->child_append_relid_sets,
 									   apath->path.pathkeys, required_outer,
 									   apath->path.parallel_workers,
 									   apath->path.parallel_aware,
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index cf3a16b8b0e..75a70489e5a 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2171,6 +2171,12 @@ typedef struct CustomPath
  * For partial Append, 'subpaths' contains non-partial subpaths followed by
  * partial subpaths.
  *
+ * Whenever accumulate_append_subpath() allows us to consolidate multiple
+ * levels of Append paths are consolidated down to one, we store the RTI
+ * sets for the omitted paths in child_append_relid_sets. This is not necessary
+ * for planning or execution; we do it for the benefit of code that wants
+ * to inspect the final plan and understand how it came to be.
+ *
  * Note: it is possible for "subpaths" to contain only one, or even no,
  * elements.  These cases are optimized during create_append_plan.
  * In particular, an AppendPath with no subpaths is a "dummy" path that
@@ -2186,6 +2192,7 @@ typedef struct AppendPath
 	/* Index of first partial path in subpaths; list_length(subpaths) if none */
 	int			first_partial_path;
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } AppendPath;
 
 #define IS_DUMMY_APPEND(p) \
@@ -2202,12 +2209,15 @@ extern bool is_dummy_rel(RelOptInfo *rel);
 /*
  * MergeAppendPath represents a MergeAppend plan, ie, the merging of sorted
  * results from several member plans to produce similarly-sorted output.
+ *
+ * child_append_relid_sets has the same meaning here as for AppendPath.
  */
 typedef struct MergeAppendPath
 {
 	Path		path;
 	List	   *subpaths;		/* list of component Paths */
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } MergeAppendPath;
 
 /*
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index c8d9797f738..7d0846ea789 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -394,9 +394,16 @@ struct PartitionPruneInfo;		/* forward reference to struct below */
 typedef struct Append
 {
 	Plan		plan;
+
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
+
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *appendplans;
+
 	/* # of asynchronous plans */
 	int			nasyncplans;
 
@@ -426,6 +433,10 @@ typedef struct MergeAppend
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
 
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *mergeplans;
 
 	/* these fields are just like the sort-key info in struct Sort: */
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 955e9056858..4437248cb67 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -70,12 +70,14 @@ extern TidRangePath *create_tidrangescan_path(PlannerInfo *root,
 											  Relids required_outer);
 extern AppendPath *create_append_path(PlannerInfo *root, RelOptInfo *rel,
 									  List *subpaths, List *partial_subpaths,
+									  List *child_append_relid_sets,
 									  List *pathkeys, Relids required_outer,
 									  int parallel_workers, bool parallel_aware,
 									  double rows);
 extern MergeAppendPath *create_merge_append_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 List *subpaths,
+												 List *child_append_relid_sets,
 												 List *pathkeys,
 												 Relids required_outer);
 extern GroupResultPath *create_group_result_path(PlannerInfo *root,
-- 
2.39.3 (Apple Git-145)



  [application/octet-stream] v1-0002-Store-information-about-elided-nodes-in-the-final.patch (9.3K, 4-v1-0002-Store-information-about-elided-nodes-in-the-final.patch)
  download | inline diff:
From 22a3213ecac00b088946f6f9512c13d4e4c0d36f Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:42 -0400
Subject: [PATCH v1 2/6] Store information about elided nodes in the final
 plan.

An extension (or core code) might want to reconstruct the planner's
choice of join order from the final plan. To do so, it must be possible
to find all of the RTIs that were part of the join problem in that plan.
The previous commit, together with the earlier work in
8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0, is enough to let us match up
RTIs we see in the final plan with RTIs that we see during the planning
cycle, but we still have a problem if the planner decides to drop some
RTIs out of the final plan altogether.

To fix that, when setrefs.c removes a SubqueryScan, single-child Append,
or single-child MergeAppend from the final Plan tree, record the type of
the removed node and the RTIs that the removed node would have scanned
in the final plan tree. It would be natural to record this information
on the child of the removed plan node, but that would require adding
an additional pointer field to type Plan, which seems undesirable.
So, instead, store the information in a separate list that the
executor need never consult, and use the plan_node_id to identify
the plan node with which the removed node is logically associated.

Also, update pg_overexplain to display these details.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 39 ++++++++++++++
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/plan/setrefs.c          | 52 ++++++++++++++++++-
 src/include/nodes/pathnodes.h                 |  3 ++
 src/include/nodes/plannodes.h                 | 17 ++++++
 src/tools/pgindent/typedefs.list              |  1 +
 7 files changed, 114 insertions(+), 3 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 55d34666d87..ca9a23ea61f 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -452,6 +452,8 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
  Seq Scan on daucus vegetables
    Filter: (genus = 'daucus'::text)
    Scan RTI: 2
+   Elided Node Type: Append
+   Elided Node RTIs: 1
  RTI 1 (relation, inherited, in-from-clause):
    Eref: vegetables (id, name, genus)
    Relation: vegetables
@@ -465,7 +467,7 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 2
-(16 rows)
+(18 rows)
 
 -- Also test a case that involves a write.
 EXPLAIN (RANGE_TABLE, COSTS OFF)
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index 5dc707d69e3..fa907fa472e 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -191,6 +191,8 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 	 */
 	if (options->range_table)
 	{
+		bool		opened_elided_nodes = false;
+
 		switch (nodeTag(plan))
 		{
 			case T_SeqScan:
@@ -251,6 +253,43 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 			default:
 				break;
 		}
+
+		foreach_node(ElidedNode, n, es->pstmt->elidedNodes)
+		{
+			char	   *elidednodetag;
+
+			if (n->plan_node_id != plan->plan_node_id)
+				continue;
+
+			if (!opened_elided_nodes)
+			{
+				ExplainOpenGroup("Elided Nodes", "Elided Nodes", false, es);
+				opened_elided_nodes = true;
+			}
+
+			switch (n->elided_type)
+			{
+				case T_Append:
+					elidednodetag = "Append";
+					break;
+				case T_MergeAppend:
+					elidednodetag = "MergeAppend";
+					break;
+				case T_SubqueryScan:
+					elidednodetag = "SubqueryScan";
+					break;
+				default:
+					elidednodetag = psprintf("%d", n->elided_type);
+					break;
+			}
+
+			ExplainOpenGroup("Elided Node", NULL, true, es);
+			ExplainPropertyText("Elided Node Type", elidednodetag, es);
+			overexplain_bitmapset("Elided Node RTIs", n->relids, es);
+			ExplainCloseGroup("Elided Node", NULL, true, es);
+		}
+		if (opened_elided_nodes)
+			ExplainCloseGroup("Elided Nodes", "Elided Nodes", false, es);
 	}
 }
 
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 0e6b3f60f31..9d5262651e7 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -618,6 +618,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->paramExecTypes = glob->paramExecTypes;
 	/* utilityStmt should be null, but we might as well copy it */
 	result->utilityStmt = parse->utilityStmt;
+	result->elidedNodes = glob->elidedNodes;
 	result->stmt_location = parse->stmt_location;
 	result->stmt_len = parse->stmt_len;
 
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index adabae09a23..23a00d452b7 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -211,6 +211,9 @@ static List *set_windowagg_runcondition_references(PlannerInfo *root,
 												   List *runcondition,
 												   Plan *plan);
 
+static void record_elided_node(PlannerGlobal *glob, int plan_node_id,
+							   NodeTag elided_type, Bitmapset *relids);
+
 
 /*****************************************************************************
  *
@@ -1460,10 +1463,17 @@ set_subqueryscan_references(PlannerInfo *root,
 
 	if (trivial_subqueryscan(plan))
 	{
+		Index		scanrelid;
+
 		/*
 		 * We can omit the SubqueryScan node and just pull up the subplan.
 		 */
 		result = clean_up_removed_plan_level((Plan *) plan, plan->subplan);
+
+		/* Remember that we removed a SubqueryScan */
+		scanrelid = plan->scan.scanrelid + rtoffset;
+		record_elided_node(root->glob, plan->subplan->plan_node_id,
+						   T_SubqueryScan, bms_make_singleton(scanrelid));
 	}
 	else
 	{
@@ -1891,7 +1901,17 @@ set_append_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(aplan->appendplans);
 
 		if (p->parallel_aware == aplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) aplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) aplan, p);
+
+			/* Remember that we removed an Append */
+			record_elided_node(root->glob, p->plan_node_id, T_Append,
+							   offset_relid_set(aplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -1959,7 +1979,17 @@ set_mergeappend_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(mplan->mergeplans);
 
 		if (p->parallel_aware == mplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) mplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) mplan, p);
+
+			/* Remember that we removed a MergeAppend */
+			record_elided_node(root->glob, p->plan_node_id, T_MergeAppend,
+							   offset_relid_set(mplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -3774,3 +3804,21 @@ extract_query_dependencies_walker(Node *node, PlannerInfo *context)
 	return expression_tree_walker(node, extract_query_dependencies_walker,
 								  context);
 }
+
+/*
+ * Record some details about a node removed from the plan during setrefs
+ * procesing, for the benefit of code trying to reconstruct planner decisions
+ * from examination of the final plan tree.
+ */
+static void
+record_elided_node(PlannerGlobal *glob, int plan_node_id,
+				   NodeTag elided_type, Bitmapset *relids)
+{
+	ElidedNode *n = makeNode(ElidedNode);
+
+	n->plan_node_id = plan_node_id;
+	n->elided_type = elided_type;
+	n->relids = relids;
+
+	glob->elidedNodes = lappend(glob->elidedNodes, n);
+}
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index a3a800869df..cf3a16b8b0e 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -159,6 +159,9 @@ typedef struct PlannerGlobal
 	/* type OIDs for PARAM_EXEC Params */
 	List	   *paramExecTypes;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/* highest PlaceHolderVar ID assigned */
 	Index		lastPHId;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index d37a6972b36..c8d9797f738 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -152,6 +152,9 @@ typedef struct PlannedStmt
 	/* non-null if this is utility stmt */
 	Node	   *utilityStmt;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/*
 	 * DefElem objects added by extensions, e.g. using planner_shutdown_hook
 	 *
@@ -1838,4 +1841,18 @@ typedef struct SubPlanRTInfo
 	bool		dummy;
 } SubPlanRTInfo;
 
+/*
+ * ElidedNode
+ *
+ * Information about nodes elided from the final plan tree: trivial subquery
+ * scans, and single-child Append and MergeAppend nodes.
+ */
+typedef struct ElidedNode
+{
+	NodeTag		type;
+	int			plan_node_id;
+	NodeTag		elided_type;
+	Bitmapset  *relids;
+} ElidedNode;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3d4a4cac0ea..f7f79fa01f4 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -697,6 +697,7 @@ EachState
 Edge
 EditableObjectType
 ElementsState
+ElidedNode
 EnableTimeoutParams
 EndDataPtrType
 EndDirectModify_function
-- 
2.39.3 (Apple Git-145)



  [application/octet-stream] v1-0001-Store-information-about-range-table-flattening-in.patch (7.9K, 5-v1-0001-Store-information-about-range-table-flattening-in.patch)
  download | inline diff:
From 9da671421efdf1d7395f39622c476193dce1ce69 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 12:00:18 -0400
Subject: [PATCH v1 1/6] Store information about range-table flattening in the
 final plan.

Suppose that we're currently planning a query and, when that same
query was previously planned and executed, we learned something about
how a certain table within that query should be planned. We want to
take note when that same table is being planned during the current
planning cycle, but this is difficult to do, because the RTI of the
table from the previous plan won't necessarily be equal to the RTI
that we see during the current planning cycle. This is because each
subquery has a separate range table during planning, but these are
flattened into one range table when constructing the final plan,
changing RTIs.

Commit 8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0 allows us to match up
subqueries seen in the previous planning cycles with the subqueries
currently being planned just by comparing textual names, but that's
not quite enough to let us deduce anything about individual tables,
because we don't know where each subquery's range table appears in
the final, flattened range table.

To fix that, store a list of SubPlanRTInfo objects in the final
planned statement, each including the name of the subplan, the offset
at which it begins in the flattened range table, and whether or not
it was a dummy subplan -- if it was, some RTIs may have been dropped
from the final range table, but also there's no need to control how
a dummy subquery gets planned. The toplevel subquery has no name and
always begins at rtoffset 0, so we make no entry for it.

This commit teaches pg_overexplain'e RANGE_TABLE option to make use
of this new data to display the subquery name for each range table
entry.

NOTE TO REVIEWERS: If there's a clean way to make pg_overexplain display
this information without the new infrastructure provided by this patch,
then this patch is unnecessary. I thought there would be a way to do
that, but I couldn't figure anything out: there seems to be nothing that
records in the final PlannedStmt where subquery's range table ends and
the next one begins. In practice, one could usually figure it out by
matching up tables by relation OID, but that's neither clean nor
theoretically sound.
---
 contrib/pg_overexplain/pg_overexplain.c | 36 +++++++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    |  1 +
 src/backend/optimizer/plan/setrefs.c    | 20 ++++++++++++++
 src/include/nodes/pathnodes.h           |  3 +++
 src/include/nodes/plannodes.h           | 17 ++++++++++++
 src/tools/pgindent/typedefs.list        |  1 +
 6 files changed, 78 insertions(+)

diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index bd70b6d9d5e..5dc707d69e3 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -395,6 +395,8 @@ static void
 overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 {
 	Index		rti;
+	ListCell   *lc_subrtinfo = list_head(plannedstmt->subrtinfos);
+	SubPlanRTInfo *rtinfo = NULL;
 
 	/* Open group, one entry per RangeTblEntry */
 	ExplainOpenGroup("Range Table", "Range Table", false, es);
@@ -405,6 +407,18 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 		RangeTblEntry *rte = rt_fetch(rti, plannedstmt->rtable);
 		char	   *kind = NULL;
 		char	   *relkind;
+		SubPlanRTInfo *next_rtinfo;
+
+		/* Advance to next SubRTInfo, if it's time. */
+		if (lc_subrtinfo != NULL)
+		{
+			next_rtinfo = lfirst(lc_subrtinfo);
+			if (rti > next_rtinfo->rtoffset)
+			{
+				rtinfo = next_rtinfo;
+				lc_subrtinfo = lnext(plannedstmt->subrtinfos, lc_subrtinfo);
+			}
+		}
 
 		/* NULL entries are possible; skip them */
 		if (rte == NULL)
@@ -469,6 +483,28 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 			ExplainPropertyBool("In From Clause", rte->inFromCl, es);
 		}
 
+		/*
+		 * Indicate which subplan is the origin of which RTE. Note dummy
+		 * subplans. Here again, we crunch more onto one line in text format.
+		 */
+		if (rtinfo != NULL)
+		{
+			if (es->format == EXPLAIN_FORMAT_TEXT)
+			{
+				if (!rtinfo->dummy)
+					ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				else
+					ExplainPropertyText("Subplan",
+										psprintf("%s (dummy)",
+												 rtinfo->plan_name), es);
+			}
+			else
+			{
+				ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				ExplainPropertyBool("Subplan Is Dummy", rtinfo->dummy, es);
+			}
+		}
+
 		/* rte->alias is optional; rte->eref is requested */
 		if (rte->alias != NULL)
 			overexplain_alias("Alias", rte->alias, es);
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index c4fd646b999..0e6b3f60f31 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -607,6 +607,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->unprunableRelids = bms_difference(glob->allRelids,
 											  glob->prunableRelids);
 	result->permInfos = glob->finalrteperminfos;
+	result->subrtinfos = glob->subrtinfos;
 	result->resultRelations = glob->resultRelations;
 	result->appendRelations = glob->appendRelations;
 	result->subplans = glob->subplans;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ccdc9bc264a..adabae09a23 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -399,6 +399,26 @@ add_rtes_to_flat_rtable(PlannerInfo *root, bool recursing)
 	Index		rti;
 	ListCell   *lc;
 
+	/*
+	 * Record enough information to make it possible for code that looks at
+	 * the final range table to understand how it was constructed. (If
+	 * finalrtable is still NIL, then this is the very topmost PlannerInfo,
+	 * which will always have plan_name == NULL and rtoffset == 0; we omit the
+	 * degenerate list entry.)
+	 */
+	if (root->glob->finalrtable != NIL)
+	{
+		SubPlanRTInfo *rtinfo = makeNode(SubPlanRTInfo);
+
+		rtinfo->plan_name = root->plan_name;
+		rtinfo->rtoffset = list_length(root->glob->finalrtable);
+
+		/* When recursing = true, it's an unplanned or dummy subquery. */
+		rtinfo->dummy = recursing;
+
+		root->glob->subrtinfos = lappend(root->glob->subrtinfos, rtinfo);
+	}
+
 	/*
 	 * Add the query's own RTEs to the flattened rangetable.
 	 *
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 30d889b54c5..a3a800869df 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -135,6 +135,9 @@ typedef struct PlannerGlobal
 	/* "flat" list of RTEPermissionInfos */
 	List	   *finalrteperminfos;
 
+	/* list of SubPlanRTInfo nodes */
+	List	   *subrtinfos;
+
 	/* "flat" list of PlanRowMarks */
 	List	   *finalrowmarks;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 7cdd2b51c94..d37a6972b36 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -131,6 +131,9 @@ typedef struct PlannedStmt
 	 */
 	List	   *subplans;
 
+	/* a list of SubPlanRTInfo objects */
+	List	   *subrtinfos;
+
 	/* indices of subplans that require REWIND */
 	Bitmapset  *rewindPlanIDs;
 
@@ -1821,4 +1824,18 @@ typedef enum MonotonicFunction
 	MONOTONICFUNC_BOTH = MONOTONICFUNC_INCREASING | MONOTONICFUNC_DECREASING,
 } MonotonicFunction;
 
+/*
+ * SubPlanRTInfo
+ *
+ * Information about which range table entries came from which subquery
+ * planning cycles.
+ */
+typedef struct SubPlanRTInfo
+{
+	NodeTag		type;
+	const char *plan_name;
+	Index		rtoffset;
+	bool		dummy;
+} SubPlanRTInfo;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index ac2da4c98cf..3d4a4cac0ea 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2886,6 +2886,7 @@ SubLink
 SubLinkType
 SubOpts
 SubPlan
+SubPlanRTInfo
 SubPlanState
 SubRelInfo
 SubRemoveRels
-- 
2.39.3 (Apple Git-145)



  [application/octet-stream] v1-0004-Temporary-hack-to-unbreak-partitionwise-join-cont.patch (15.2K, 6-v1-0004-Temporary-hack-to-unbreak-partitionwise-join-cont.patch)
  download | inline diff:
From 4acb0e8fadbc74f691aa6becea727dc197cca068 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Wed, 29 Oct 2025 15:17:46 -0400
Subject: [PATCH v1 4/6] Temporary hack to unbreak partitionwise join control.

Resetting the pathlist and partial pathlist to NIL when the
topmost scan/join rel is a partitioned joinrel is incorrect. The issue
was originally reported by Ashutosh Bapat here:

http://postgr.es/m/CAExHW5toze58+jL-454J3ty11sqJyU13Sz5rJPQZDmASwZgWiA@mail.gmail.com

I failed to understand Ashutosh's explanation until I hit the problem
myself, so here's my attempt to re-explain what he had said, just in
case you find my explanation any clearer:

http://postgr.es/m/CA%2BTgmoZvBD%2B5vyQruXBVXW74FMgWxE%3DO4K4rCrCtEELWNj-MLA%40mail.gmail.com

As subsequent discussion on that thread indicates, it is unclear
exactly what the right fix for this problem is, and at least as of
this writing, it is even more unclear how to adjust the test cases
that break. What I've done here is just accept all the changes to the
regression test outputs, which is almost certainly the wrong idea,
especially since I've also added no comments.

This is just a temporary hack to make it possible to test this patch
set, because without this, PARTITIONWISE() advice can't be used to
suppress a partitionwise join, because all of the alternatives get
eliminated regardless of cost.
---
 src/backend/optimizer/plan/planner.c         |   4 +-
 src/test/regress/expected/partition_join.out | 172 ++++++++-----------
 src/test/regress/expected/subselect.out      |  41 ++---
 3 files changed, 91 insertions(+), 126 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index eb62794aecd..8b1ab847f39 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -7927,7 +7927,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	 * generate_useful_gather_paths to add path(s) to the main list, and
 	 * finally zap the partial pathlist.
 	 */
-	if (rel_is_partitioned)
+	if (rel_is_partitioned && IS_SIMPLE_REL(rel))
 		rel->pathlist = NIL;
 
 	/*
@@ -7953,7 +7953,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	}
 
 	/* Finish dropping old paths for a partitioned rel, per comment above */
-	if (rel_is_partitioned)
+	if (rel_is_partitioned && IS_SIMPLE_REL(rel))
 		rel->partial_pathlist = NIL;
 
 	/* Extract SRF-free scan/join target. */
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index 713828be335..3e34f05ba62 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -65,31 +65,24 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
 -- inner join with partially-redundant join clauses
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
-                          QUERY PLAN                           
----------------------------------------------------------------
- Sort
-   Sort Key: t1.a
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Merge Join
+   Merge Cond: (t1.a = t2.a)
    ->  Append
-         ->  Merge Join
-               Merge Cond: (t1_1.a = t2_1.a)
-               ->  Index Scan using iprt1_p1_a on prt1_p1 t1_1
-               ->  Sort
-                     Sort Key: t2_1.b
-                     ->  Seq Scan on prt2_p1 t2_1
-                           Filter: (a = b)
-         ->  Hash Join
-               Hash Cond: (t1_2.a = t2_2.a)
-               ->  Seq Scan on prt1_p2 t1_2
-               ->  Hash
-                     ->  Seq Scan on prt2_p2 t2_2
-                           Filter: (a = b)
-         ->  Hash Join
-               Hash Cond: (t1_3.a = t2_3.a)
-               ->  Seq Scan on prt1_p3 t1_3
-               ->  Hash
-                     ->  Seq Scan on prt2_p3 t2_3
-                           Filter: (a = b)
-(22 rows)
+         ->  Index Scan using iprt1_p1_a on prt1_p1 t1_1
+         ->  Index Scan using iprt1_p2_a on prt1_p2 t1_2
+         ->  Index Scan using iprt1_p3_a on prt1_p3 t1_3
+   ->  Sort
+         Sort Key: t2.b
+         ->  Append
+               ->  Seq Scan on prt2_p1 t2_1
+                     Filter: (a = b)
+               ->  Seq Scan on prt2_p2 t2_2
+                     Filter: (a = b)
+               ->  Seq Scan on prt2_p3 t2_3
+                     Filter: (a = b)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
  a  |  c   | b  |  c   
@@ -1249,56 +1242,50 @@ SET enable_hashjoin TO off;
 SET enable_nestloop TO off;
 EXPLAIN (COSTS OFF)
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
-                            QUERY PLAN                            
-------------------------------------------------------------------
- Merge Append
-   Sort Key: t1.a
-   ->  Merge Semi Join
-         Merge Cond: (t1_3.a = t1_6.b)
-         ->  Sort
-               Sort Key: t1_3.a
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Merge Join
+   Merge Cond: (t1.a = t1_1.b)
+   ->  Sort
+         Sort Key: t1.a
+         ->  Append
                ->  Seq Scan on prt1_p1 t1_3
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_6.b = (((t1_9.a + t1_9.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_6.b
-                     ->  Seq Scan on prt2_p1 t1_6
-               ->  Sort
-                     Sort Key: (((t1_9.a + t1_9.b) / 2))
-                     ->  Seq Scan on prt1_e_p1 t1_9
-                           Filter: (c = 0)
-   ->  Merge Semi Join
-         Merge Cond: (t1_4.a = t1_7.b)
-         ->  Sort
-               Sort Key: t1_4.a
                ->  Seq Scan on prt1_p2 t1_4
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_7.b = (((t1_10.a + t1_10.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_7.b
-                     ->  Seq Scan on prt2_p2 t1_7
-               ->  Sort
-                     Sort Key: (((t1_10.a + t1_10.b) / 2))
-                     ->  Seq Scan on prt1_e_p2 t1_10
-                           Filter: (c = 0)
-   ->  Merge Semi Join
-         Merge Cond: (t1_5.a = t1_8.b)
-         ->  Sort
-               Sort Key: t1_5.a
                ->  Seq Scan on prt1_p3 t1_5
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_8.b = (((t1_11.a + t1_11.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_8.b
-                     ->  Seq Scan on prt2_p3 t1_8
-               ->  Sort
-                     Sort Key: (((t1_11.a + t1_11.b) / 2))
-                     ->  Seq Scan on prt1_e_p3 t1_11
-                           Filter: (c = 0)
-(47 rows)
+   ->  Unique
+         ->  Merge Append
+               Sort Key: t1_1.b
+               ->  Merge Semi Join
+                     Merge Cond: (t1_6.b = (((t1_9.a + t1_9.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_6.b
+                           ->  Seq Scan on prt2_p1 t1_6
+                     ->  Sort
+                           Sort Key: (((t1_9.a + t1_9.b) / 2))
+                           ->  Seq Scan on prt1_e_p1 t1_9
+                                 Filter: (c = 0)
+               ->  Merge Semi Join
+                     Merge Cond: (t1_7.b = (((t1_10.a + t1_10.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_7.b
+                           ->  Seq Scan on prt2_p2 t1_7
+                     ->  Sort
+                           Sort Key: (((t1_10.a + t1_10.b) / 2))
+                           ->  Seq Scan on prt1_e_p2 t1_10
+                                 Filter: (c = 0)
+               ->  Merge Semi Join
+                     Merge Cond: (t1_8.b = (((t1_11.a + t1_11.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_8.b
+                           ->  Seq Scan on prt2_p3 t1_8
+                     ->  Sort
+                           Sort Key: (((t1_11.a + t1_11.b) / 2))
+                           ->  Seq Scan on prt1_e_p3 t1_11
+                                 Filter: (c = 0)
+(41 rows)
 
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -4923,32 +4910,27 @@ ANALYZE plt3_adv;
 -- '0001' of that partition
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM (plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.c = t2.c)) FULL JOIN plt3_adv t3 ON (t1.c = t3.c) WHERE coalesce(t1.a, 0) % 5 != 3 AND coalesce(t1.a, 0) % 5 != 4 ORDER BY t1.c, t1.a, t2.a, t3.a;
-                                          QUERY PLAN                                           
------------------------------------------------------------------------------------------------
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
  Sort
    Sort Key: t1.c, t1.a, t2.a, t3.a
-   ->  Append
-         ->  Hash Full Join
-               Hash Cond: (t1_1.c = t3_1.c)
-               Filter: (((COALESCE(t1_1.a, 0) % 5) <> 3) AND ((COALESCE(t1_1.a, 0) % 5) <> 4))
-               ->  Hash Left Join
-                     Hash Cond: (t1_1.c = t2_1.c)
+   ->  Hash Full Join
+         Hash Cond: (t1.c = t3.c)
+         Filter: (((COALESCE(t1.a, 0) % 5) <> 3) AND ((COALESCE(t1.a, 0) % 5) <> 4))
+         ->  Hash Left Join
+               Hash Cond: (t1.c = t2.c)
+               ->  Append
                      ->  Seq Scan on plt1_adv_p1 t1_1
-                     ->  Hash
-                           ->  Seq Scan on plt2_adv_p1 t2_1
-               ->  Hash
-                     ->  Seq Scan on plt3_adv_p1 t3_1
-         ->  Hash Full Join
-               Hash Cond: (t1_2.c = t3_2.c)
-               Filter: (((COALESCE(t1_2.a, 0) % 5) <> 3) AND ((COALESCE(t1_2.a, 0) % 5) <> 4))
-               ->  Hash Left Join
-                     Hash Cond: (t1_2.c = t2_2.c)
                      ->  Seq Scan on plt1_adv_p2 t1_2
-                     ->  Hash
-                           ->  Seq Scan on plt2_adv_p2 t2_2
                ->  Hash
+                     ->  Append
+                           ->  Seq Scan on plt2_adv_p1 t2_1
+                           ->  Seq Scan on plt2_adv_p2 t2_2
+         ->  Hash
+               ->  Append
+                     ->  Seq Scan on plt3_adv_p1 t3_1
                      ->  Seq Scan on plt3_adv_p2 t3_2
-(23 rows)
+(18 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM (plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.c = t2.c)) FULL JOIN plt3_adv t3 ON (t1.c = t3.c) WHERE coalesce(t1.a, 0) % 5 != 3 AND coalesce(t1.a, 0) % 5 != 4 ORDER BY t1.c, t1.a, t2.a, t3.a;
  a  |  c   | a  |  c   | a  |  c   
@@ -5240,17 +5222,15 @@ SELECT x.id, y.id FROM fract_t x LEFT JOIN fract_t y USING (id) ORDER BY x.id AS
                               QUERY PLAN                               
 -----------------------------------------------------------------------
  Limit
-   ->  Merge Append
-         Sort Key: x.id
-         ->  Merge Left Join
-               Merge Cond: (x_1.id = y_1.id)
+   ->  Merge Left Join
+         Merge Cond: (x.id = y.id)
+         ->  Append
                ->  Index Only Scan using fract_t0_pkey on fract_t0 x_1
-               ->  Index Only Scan using fract_t0_pkey on fract_t0 y_1
-         ->  Merge Left Join
-               Merge Cond: (x_2.id = y_2.id)
                ->  Index Only Scan using fract_t1_pkey on fract_t1 x_2
+         ->  Append
+               ->  Index Only Scan using fract_t0_pkey on fract_t0 y_1
                ->  Index Only Scan using fract_t1_pkey on fract_t1 y_2
-(11 rows)
+(9 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT x.id, y.id FROM fract_t x LEFT JOIN fract_t y USING (id) ORDER BY x.id DESC LIMIT 10;
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index cf6b32d1173..8549601e3bc 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -850,10 +850,11 @@ where (t1.a, t2.a) in (select a, a from unique_tbl_p t3)
 order by t1.a, t2.a;
                                            QUERY PLAN                                           
 ------------------------------------------------------------------------------------------------
- Merge Append
-   Sort Key: t1.a
-   ->  Nested Loop
-         Output: t1_1.a, t1_1.b, t2_1.a, t2_1.b
+ Merge Join
+   Output: t1.a, t1.b, t2.a, t2.b
+   Merge Cond: (t1.a = t2.a)
+   ->  Merge Append
+         Sort Key: t1.a
          ->  Nested Loop
                Output: t1_1.a, t1_1.b, t3_1.a
                ->  Unique
@@ -863,15 +864,6 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t1_1
                      Output: t1_1.a, t1_1.b
                      Index Cond: (t1_1.a = t3_1.a)
-         ->  Memoize
-               Output: t2_1.a, t2_1.b
-               Cache Key: t1_1.a
-               Cache Mode: logical
-               ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t2_1
-                     Output: t2_1.a, t2_1.b
-                     Index Cond: (t2_1.a = t1_1.a)
-   ->  Nested Loop
-         Output: t1_2.a, t1_2.b, t2_2.a, t2_2.b
          ->  Nested Loop
                Output: t1_2.a, t1_2.b, t3_2.a
                ->  Unique
@@ -881,15 +873,6 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t1_2
                      Output: t1_2.a, t1_2.b
                      Index Cond: (t1_2.a = t3_2.a)
-         ->  Memoize
-               Output: t2_2.a, t2_2.b
-               Cache Key: t1_2.a
-               Cache Mode: logical
-               ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t2_2
-                     Output: t2_2.a, t2_2.b
-                     Index Cond: (t2_2.a = t1_2.a)
-   ->  Nested Loop
-         Output: t1_3.a, t1_3.b, t2_3.a, t2_3.b
          ->  Nested Loop
                Output: t1_3.a, t1_3.b, t3_3.a
                ->  Unique
@@ -902,14 +885,16 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p3_a_idx on public.unique_tbl_p3 t1_3
                      Output: t1_3.a, t1_3.b
                      Index Cond: (t1_3.a = t3_3.a)
-         ->  Memoize
-               Output: t2_3.a, t2_3.b
-               Cache Key: t1_3.a
-               Cache Mode: logical
+   ->  Materialize
+         Output: t2.a, t2.b
+         ->  Append
+               ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t2_1
+                     Output: t2_1.a, t2_1.b
+               ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t2_2
+                     Output: t2_2.a, t2_2.b
                ->  Index Scan using unique_tbl_p3_a_idx on public.unique_tbl_p3 t2_3
                      Output: t2_3.a, t2_3.b
-                     Index Cond: (t2_3.a = t1_3.a)
-(59 rows)
+(44 rows)
 
 reset enable_partitionwise_join;
 drop table unique_tbl_p;
-- 
2.39.3 (Apple Git-145)



  [application/octet-stream] v1-0006-WIP-Add-pg_plan_advice-contrib-module.patch (354.3K, 7-v1-0006-WIP-Add-pg_plan_advice-contrib-module.patch)
  download | inline diff:
From bce12842af5ebef35f9ff8b04dc6a19a2b3c26b2 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Thu, 30 Oct 2025 09:14:23 -0400
Subject: [PATCH v1 6/6] WIP: Add pg_plan_advice contrib module.

Provide a facility that (1) can be used to stabilize certain plan choices
so that the planner cannot reverse course without authorization and
(2) can be used by knowledgeable users to insist on plan choices contrary
to what the planner believes best. In both cases, terrible outcomes are
possible: users should think twice and perhaps three times before
constraining the planner's ability to do as it thinks best; nevertheless,
there are problems that are much more easily solved with these facilities
than without them.

We take the approach of analyzing a finished plan to produce textual
output, which we call "plan advice", that describes key decisions made
during plan; if that plan advice is provided during future planning
cycles, it will force those key decisions to be made in the same way.
Not all planner decisions can be controlled using advice; for example,
decisions about how to perform aggregation are currently out of scope,
as is choice of sort order. Plan advice can also be edited by the user,
or even written from scratch in simple cases, making it possible to
generate outcomes that the planner would not have produced. Partial
advice can be provided to control some planner outcomes but not others.

Currently, plan advice is focused only on specific outcomes, such as
the choice to use a sequential scan for a particular relation, and not
on estimates that might contribute to those outcomes, such as a
possibly-incorrect selectivity estimate. While it would be useful to
users to be able to provide plan advice that affects selectivity
estimates or other aspects of costing, that is out of scope for this
commit.

For more details, see contrib/pg_plan_advice/README.

NOTE: This code is just a proof of concept. A bunch of things don't
work and a lot of the code needs cleanup. It has no SGML documentation
and not enough test cases, and some of the existing test cases don't
do as we would hope. Known problems are called out by XXX.
---
 contrib/Makefile                              |    1 +
 contrib/meson.build                           |    1 +
 contrib/pg_plan_advice/.gitignore             |    3 +
 contrib/pg_plan_advice/Makefile               |   45 +
 contrib/pg_plan_advice/README                 |  275 +++
 contrib/pg_plan_advice/expected/gather.out    |  319 +++
 .../pg_plan_advice/expected/join_order.out    |  292 +++
 .../pg_plan_advice/expected/join_strategy.out |  297 +++
 .../pg_plan_advice/expected/partitionwise.out |  243 +++
 contrib/pg_plan_advice/expected/scan.out      |  477 +++++
 contrib/pg_plan_advice/meson.build            |   63 +
 .../pg_plan_advice/pg_plan_advice--1.0.sql    |   57 +
 contrib/pg_plan_advice/pg_plan_advice.c       |  454 +++++
 contrib/pg_plan_advice/pg_plan_advice.control |    5 +
 contrib/pg_plan_advice/pg_plan_advice.h       |   37 +
 contrib/pg_plan_advice/pgpa_ast.c             |  647 +++++++
 contrib/pg_plan_advice/pgpa_ast.h             |  204 ++
 contrib/pg_plan_advice/pgpa_collector.c       |  626 ++++++
 contrib/pg_plan_advice/pgpa_collector.h       |   18 +
 contrib/pg_plan_advice/pgpa_identifier.c      |  476 +++++
 contrib/pg_plan_advice/pgpa_identifier.h      |   52 +
 contrib/pg_plan_advice/pgpa_join.c            |  615 ++++++
 contrib/pg_plan_advice/pgpa_join.h            |  105 +
 contrib/pg_plan_advice/pgpa_output.c          |  624 ++++++
 contrib/pg_plan_advice/pgpa_output.h          |   22 +
 contrib/pg_plan_advice/pgpa_parser.y          |  337 ++++
 contrib/pg_plan_advice/pgpa_planner.c         | 1706 +++++++++++++++++
 contrib/pg_plan_advice/pgpa_planner.h         |   17 +
 contrib/pg_plan_advice/pgpa_scan.c            |  278 +++
 contrib/pg_plan_advice/pgpa_scan.h            |   86 +
 contrib/pg_plan_advice/pgpa_scanner.l         |  299 +++
 contrib/pg_plan_advice/pgpa_trove.c           |  487 +++++
 contrib/pg_plan_advice/pgpa_trove.h           |  113 ++
 contrib/pg_plan_advice/pgpa_walker.c          |  878 +++++++++
 contrib/pg_plan_advice/pgpa_walker.h          |  121 ++
 contrib/pg_plan_advice/sql/gather.sql         |   75 +
 contrib/pg_plan_advice/sql/join_order.sql     |   96 +
 contrib/pg_plan_advice/sql/join_strategy.sql  |   76 +
 contrib/pg_plan_advice/sql/partitionwise.sql  |   78 +
 contrib/pg_plan_advice/sql/scan.sql           |  132 ++
 src/tools/pgindent/typedefs.list              |   37 +
 41 files changed, 10774 insertions(+)
 create mode 100644 contrib/pg_plan_advice/.gitignore
 create mode 100644 contrib/pg_plan_advice/Makefile
 create mode 100644 contrib/pg_plan_advice/README
 create mode 100644 contrib/pg_plan_advice/expected/gather.out
 create mode 100644 contrib/pg_plan_advice/expected/join_order.out
 create mode 100644 contrib/pg_plan_advice/expected/join_strategy.out
 create mode 100644 contrib/pg_plan_advice/expected/partitionwise.out
 create mode 100644 contrib/pg_plan_advice/expected/scan.out
 create mode 100644 contrib/pg_plan_advice/meson.build
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice--1.0.sql
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.c
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.control
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.h
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.c
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.h
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.c
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.h
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.c
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.h
 create mode 100644 contrib/pg_plan_advice/pgpa_join.c
 create mode 100644 contrib/pg_plan_advice/pgpa_join.h
 create mode 100644 contrib/pg_plan_advice/pgpa_output.c
 create mode 100644 contrib/pg_plan_advice/pgpa_output.h
 create mode 100644 contrib/pg_plan_advice/pgpa_parser.y
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.c
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.c
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scanner.l
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.c
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.h
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.c
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.h
 create mode 100644 contrib/pg_plan_advice/sql/gather.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_order.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_strategy.sql
 create mode 100644 contrib/pg_plan_advice/sql/partitionwise.sql
 create mode 100644 contrib/pg_plan_advice/sql/scan.sql

diff --git a/contrib/Makefile b/contrib/Makefile
index 2f0a88d3f77..dd04c20acd2 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -34,6 +34,7 @@ SUBDIRS = \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
+		pg_plan_advice \
 		pg_prewarm	\
 		pg_stat_statements \
 		pg_surgery	\
diff --git a/contrib/meson.build b/contrib/meson.build
index ed30ee7d639..cb718dbdac0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -48,6 +48,7 @@ subdir('pgcrypto')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
+subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
 subdir('pg_stat_statements')
diff --git a/contrib/pg_plan_advice/.gitignore b/contrib/pg_plan_advice/.gitignore
new file mode 100644
index 00000000000..19a14253019
--- /dev/null
+++ b/contrib/pg_plan_advice/.gitignore
@@ -0,0 +1,3 @@
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
new file mode 100644
index 00000000000..27d3451d574
--- /dev/null
+++ b/contrib/pg_plan_advice/Makefile
@@ -0,0 +1,45 @@
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_plan_advice.o \
+	pgpa_ast.o \
+	pgpa_collector.o \
+	pgpa_identifier.o \
+	pgpa_join.o \
+	pgpa_output.o \
+	pgpa_parser.o \
+	pgpa_planner.o \
+	pgpa_scan.o \
+	pgpa_scanner.o \
+	pgpa_trove.o \
+	pgpa_walker.o
+
+EXTENSION = pg_plan_advice
+DATA = pg_plan_advice--1.0.sql
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise scan
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+	touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
new file mode 100644
index 00000000000..e6732ddee7a
--- /dev/null
+++ b/contrib/pg_plan_advice/README
@@ -0,0 +1,275 @@
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planing
+cycle, each piece of advice should be guaranteed to apply to the exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should succesfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it is appears to be good advice or bad advice.  If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+   e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously.  Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables.  When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by curly brances, e.g. SOME_SORT_OF_SCAN(foo (bar baz)) would mean that the
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+Bitmap heap scans allow for a more complicated index specification. For
+example, BITMAP_HEAP_SCAN(foo &&(foo_a_idx ||(foo_b_idx foo_c_idx))) says
+that foo should be scanned using a BitmapHeapScan over a BitmapAnd between
+foo_a_idx and the result of a BitmapOr between foo_b_idx and foo_c_idx.
+
+XXX: Currently, BITMAP_HEAP_SCAN does not enforce the index specification,
+because the available hooks are insufficient to do so. It's possible that
+this should be changed to exclude the index specification altogether and
+simply insist that some sort of bitmap heap scan is used; alternatively,
+we need better hooks.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ADVICE() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructured. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Strategy Advice
+====================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+strategy advice; no join strategy advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join strategy advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the pelanner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join strategy advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join strategy advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join strategy advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+XXX: Currently, SEMIJOIN_NON_UNIQUE() advice is emitted in some situations
+where the SEMIJOIN_UNIQUE() approach was determined to be non-viable; ideally,
+we should avoid that.
+
+XXX: Right semijoins haven't been properly thought through. The associated
+code probably just doesn't work.
+
+XXX: Semijoin uniqueness advice has no automated tests and need substantially
+more manual testing.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advise can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constraint the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the outer
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constraint won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
new file mode 100644
index 00000000000..45c44aff82a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -0,0 +1,319 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Sort Key: f.dim_id
+   ->  Gather
+         Workers Planned: 1
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(f d)
+(20 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(d)
+   NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(d)
+   NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   NO_GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Disabled: true
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(14 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
new file mode 100644
index 00000000000..e87652370c3
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -0,0 +1,292 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   HASH_JOIN(d1 d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (d1.id = f.dim1_id)
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Hash
+               ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 f d2)
+   HASH_JOIN(f d2)
+   SEQ_SCAN(d1 f d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN(d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Disabled: true
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_PLAIN(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
new file mode 100644
index 00000000000..71ee26a337a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -0,0 +1,297 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Disabled: true
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Materialize
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MATERIALIZE(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Memoize
+         Cache Key: f.dim_id
+         Cache Mode: logical
+         ->  Index Scan using join_dim_pkey on join_dim d
+               Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MEMOIZE(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN              
+-------------------------------------
+ Hash Join
+   Hash Cond: (d.id = f.dim_id)
+   ->  Seq Scan on join_dim d
+   ->  Hash
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   HASH_JOIN(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Materialize
+         ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_MATERIALIZE(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_dim d
+   ->  Materialize
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MATERIALIZE(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Memoize
+         Cache Key: d.id
+         Cache Mode: logical
+         ->  Index Scan using join_fact_dim_id on join_fact f
+               Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MEMOIZE(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+         Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_PLAIN(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(13 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
new file mode 100644
index 00000000000..df0f05531d5
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -0,0 +1,243 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt2.id)
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1b pt1_2
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1c pt1_3
+               Filter: (val1 = 1)
+   ->  Hash
+         ->  Hash Join
+               Hash Cond: (pt2.id = pt3.id)
+               ->  Append
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+               ->  Hash
+                     ->  Append
+                           ->  Seq Scan on pt3a pt3_1
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3b pt3_2
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3c pt3_3
+                                 Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE(pt1) /* matched */
+   PARTITIONWISE(pt2) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 (pt2 pt3))
+   HASH_JOIN(pt3 pt3)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE(pt1 pt2 pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(40 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt3.id)
+   ->  Append
+         ->  Hash Join
+               Hash Cond: (pt1_1.id = pt2_1.id)
+               ->  Seq Scan on pt1a pt1_1
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_2.id = pt2_2.id)
+               ->  Seq Scan on pt1b pt1_2
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_3.id = pt2_3.id)
+               ->  Seq Scan on pt1c pt1_3
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+   ->  Hash
+         ->  Append
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3b pt3_2
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3c pt3_3
+                     Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+   JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+   JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+   JOIN_ORDER({pt1 pt2} pt3)
+   HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+   SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+    pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE((pt1 pt2) pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+         ->  Seq Scan on pt1b pt1_2
+         ->  Seq Scan on pt1c pt1_3
+   ->  Append
+         ->  Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+               Index Cond: (id = pt1.id)
+         ->  Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+               Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 ptmismatch)
+   NESTED_LOOP_PLAIN(ptmismatch)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+    ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+   PARTITIONWISE(pt1 ptmismatch)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+    ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
new file mode 100644
index 00000000000..0859155330c
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -0,0 +1,477 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+       QUERY PLAN        
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                     QUERY PLAN                     
+----------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+            QUERY PLAN             
+-----------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_b) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a > 0)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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 > 0;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a > 0)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (a > 0)
+   ->  Bitmap Index Scan on scan_table_pkey
+         Index Cond: (a > 0)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(9 rows)
+
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_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:
+   TID_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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
new file mode 100644
index 00000000000..dafb3fce9b5
--- /dev/null
+++ b/contrib/pg_plan_advice/meson.build
@@ -0,0 +1,63 @@
+# Copyright (c) 2022-2024, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+  'pg_plan_advice.c',
+  'pgpa_ast.c',
+  'pgpa_collector.c',
+  'pgpa_identifier.c',
+  'pgpa_join.c',
+  'pgpa_output.c',
+  'pgpa_planner.c',
+  'pgpa_scan.c',
+  'pgpa_trove.c',
+  'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+  input: 'pgpa_scanner.l',
+  output: 'pgpa_scanner.c',
+  command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+  input: 'pgpa_parser.y',
+  kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+  pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_plan_advice',
+    '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice = shared_module('pg_plan_advice',
+  pg_plan_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_data(
+  'pg_plan_advice--1.0.sql',
+  'pg_plan_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gather',
+      'join_order',
+      'join_strategy',
+      'partitionwise',
+      'scan',
+    ],
+  },
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice--1.0.sql b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
new file mode 100644
index 00000000000..29b1e23bf3e
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
@@ -0,0 +1,57 @@
+/* contrib/pg_plan_advice/pg_plan_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_plan_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
+
+CREATE FUNCTION pg_parse_advice(
+	advice_string text,
+	OUT path int[],
+	OUT tag text,
+	OUT operator text,
+	OUT alias text,
+	OUT occurrence integer,
+	OUT relschema text,
+	OUT relname text,
+	OUT plan_name text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_parse_advice'
+LANGUAGE C STRICT;
diff --git a/contrib/pg_plan_advice/pg_plan_advice.c b/contrib/pg_plan_advice/pg_plan_advice.c
new file mode 100644
index 00000000000..f32e8b7a0d3
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.c
@@ -0,0 +1,454 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ *	  main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static pgpa_shared_state *pgpa_state = NULL;
+static dsa_area *pgpa_dsa_area = NULL;
+
+/* GUC variables */
+char	   *pg_plan_advice_advice = NULL;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+int			pg_plan_advice_local_collection_limit = 0;
+int			pg_plan_advice_shared_collection_limit = 0;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int	es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+												  DefElem *opt,
+												  ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+												 IntoClause *into,
+												 ExplainState *es,
+												 const char *queryString,
+												 ParamListInfo params,
+												 QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+											 GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("pg_plan_advice.advice",
+							   "advice to apply during query planning",
+							   NULL,
+							   &pg_plan_advice_advice,
+							   NULL,
+							   PGC_USERSET,
+							   0,
+							   pg_plan_advice_advice_check_hook,
+							   NULL,
+							   NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+							 "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+							 NULL,
+							 &pg_plan_advice_always_explain_supplied_advice,
+							 true,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_plan_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_plan_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_plan_advice");
+
+	/* Get an ID that we can use to cache data in an ExplainState. */
+	es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+	/* Register the new EXPLAIN options implemented by this module. */
+	RegisterExtensionExplainOption("plan_advice",
+								   pg_plan_advice_explain_option_handler);
+
+	/* Install hooks */
+	pgpa_planner_install_hooks();
+	prev_explain_per_plan = explain_per_plan_hook;
+	explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgpa_init_shared_state(void *ptr)
+{
+	pgpa_shared_state *state = (pgpa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock, LWLockNewTrancheId("pg_plan_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_plan_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+	if (pgpa_memory_context == NULL)
+		pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_plan_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgpa_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ *
+ * Along the way, make sure the relevant LWLock tranches are registered.
+ */
+pgpa_shared_state *
+pg_plan_advice_attach(void)
+{
+	if (pgpa_state == NULL)
+	{
+		bool		found;
+
+		pgpa_state =
+			GetNamedDSMSegment("pg_plan_advice", sizeof(pgpa_shared_state),
+							   pgpa_init_shared_state, &found);
+	}
+
+	return pgpa_state;
+}
+
+/*
+ * Return a pointer to pg_plan_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_plan_advice_dsa_area(void)
+{
+	if (pgpa_dsa_area == NULL)
+	{
+		pgpa_shared_state *state = pg_plan_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgpa_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgpa_dsa_area);
+			state->area = dsa_get_handle(pgpa_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgpa_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgpa_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgpa_dsa_area;
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+									  ParseState *pstate)
+{
+	bool	   *plan_advice;
+
+	plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+	if (plan_advice == NULL)
+	{
+		plan_advice = palloc0_object(bool);
+		SetExplainExtensionState(es, es_extension_id, plan_advice);
+	}
+
+	*plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+									  char *value)
+{
+	char	   *s;
+
+	/* For non-text formats, it's best not to add any special handling. */
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		ExplainPropertyText(qlabel, value, es);
+		return;
+	}
+
+	/* In text format, if there is no data, display nothing. */
+	if (*qlabel == '\0')
+		return;
+
+	/*
+	 * It looks nicest to indent each line of the advice separately, beginning
+	 * on the line below the label.
+	 */
+	ExplainIndentText(es);
+	appendStringInfo(es->str, "%s:\n", qlabel);
+	es->indent++;
+	while ((s = strchr(value, '\n')) != NULL)
+	{
+		ExplainIndentText(es);
+		appendBinaryStringInfo(es->str, value, (s - value) + 1);
+		value = s + 1;
+	}
+
+	/* Don't interpret a terminal newline as a request for an empty line. */
+	if (*value != '\0')
+	{
+		ExplainIndentText(es);
+		appendStringInfo(es->str, "%s\n", value);
+	}
+
+	es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		appendStringInfo(&buf, "%s /* ", item->defname);
+		if ((flags & PGPA_TE_MATCH_FULL) != 0)
+		{
+			Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+			appendStringInfo(&buf, "matched");
+		}
+		else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+			appendStringInfo(&buf, "partially matched");
+		else
+			appendStringInfo(&buf, "not matched");
+		if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+			appendStringInfo(&buf, ", inapplicable");
+		if ((flags & PGPA_TE_CONFLICTING) != 0)
+			appendStringInfo(&buf, ", conflicting");
+		if ((flags & PGPA_TE_FAILED) != 0)
+			appendStringInfo(&buf, ", failed");
+		appendStringInfo(&buf, " */\n");
+	}
+
+	pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+										  buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+									 IntoClause *into,
+									 ExplainState *es,
+									 const char *queryString,
+									 ParamListInfo params,
+									 QueryEnvironment *queryEnv)
+{
+	bool	   *plan_advice = GetExplainExtensionState(es, es_extension_id);
+	DefElem    *pgpa_item;
+	List	   *pgpa_list;
+
+	if (prev_explain_per_plan)
+		prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+							  queryEnv);
+
+	/* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+	pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+										"pg_plan_advice");
+	pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+	/*
+	 * By default, if there is a record of attempting to apply advice during
+	 * query planning, we always output that information, but the user can set
+	 * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+	 * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+	 * was specified and not set to false.
+	 *
+	 * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+	 * statement -- the application of query advice may not have been
+	 * recorded, and therefore this won't be able to show anything.
+	 */
+	if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+							  (plan_advice != NULL && *plan_advice)))
+	{
+		DefElem    *feedback;
+
+		feedback = find_defelem_by_defname(pgpa_list, "feedback");
+		if (feedback != NULL)
+			pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+	}
+
+	/*
+	 * If the PLAN_ADVICE option was specified -- and not sent to FALSE --
+	 * show generated advice.
+	 */
+	if (plan_advice != NULL && *plan_advice)
+	{
+		DefElem    *advice_string_item;
+		char	   *advice_string;
+
+		advice_string_item =
+			find_defelem_by_defname(pgpa_list, "advice_string");
+		if (advice_string_item != NULL)
+		{
+			/* Advice has already been generated; we can reuse it. */
+			advice_string = strVal(advice_string_item->arg);
+		}
+		else
+		{
+			pgpa_plan_walker_context walker;
+			StringInfoData buf;
+			pgpa_identifier *rt_identifiers;
+
+			/* Advice not yet generated; do that now. */
+			pgpa_plan_walker(&walker, plannedstmt);
+			rt_identifiers =
+				pgpa_create_identifiers_for_planned_stmt(plannedstmt);
+			initStringInfo(&buf);
+			pgpa_output_advice(&buf, &walker, rt_identifiers);
+			advice_string = buf.data;
+		}
+
+		if (advice_string[0] != '\0')
+			pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+												  advice_string);
+	}
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+	MemoryContext oldcontext;
+	MemoryContext tmpcontext;
+	char	   *error;
+
+	if (*newval == NULL)
+		return true;
+
+	tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+									   "pg_plan_advice.advice",
+									   ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+	/*
+	 * It would be nice to save the parse tree that we construct here for
+	 * eventual use when planning with this advice, but *extra can only point
+	 * to a single guc_malloc'd chunk, and our parse tree involves an
+	 * arbitrary number of memory allocations.
+	 */
+	(void) pgpa_parse(*newval, &error);
+
+	if (error != NULL)
+	{
+		GUC_check_errdetail("Could not parse advice: %s", error);
+		return false;
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+	MemoryContextDelete(tmpcontext);
+
+	return true;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.control b/contrib/pg_plan_advice/pg_plan_advice.control
new file mode 100644
index 00000000000..aa6fdc9e7b2
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.control
@@ -0,0 +1,5 @@
+# pg_plan_advice extension
+comment = 'help the planner get the right plan'
+default_version = '1.0'
+module_pathname = '$libdir/pg_plan_advice'
+relocatable = true
diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
new file mode 100644
index 00000000000..86efb3b6113
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -0,0 +1,37 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ *	  main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "nodes/plannodes.h"
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgpa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgpa_shared_state;
+
+/* GUC variables */
+extern int	pg_plan_advice_local_collection_limit;
+extern int	pg_plan_advice_shared_collection_limit;
+extern char *pg_plan_advice_advice;
+
+/* Function prototypes */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern pgpa_shared_state *pg_plan_advice_attach(void);
+extern dsa_area *pg_plan_advice_dsa_area(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
new file mode 100644
index 00000000000..ed18950af18
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -0,0 +1,647 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ *	  additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+PG_FUNCTION_INFO_V1(pg_parse_advice);
+
+#define PG_PARSE_ADVICE_COLUMNS	8
+
+static void pgpa_describe_target(ReturnSetInfo *rsinfo,
+								 pgpa_advice_target *target,
+								 int path_length, Datum *path_datums);
+static void pgpa_describe_index(ReturnSetInfo *rsinfo,
+								pgpa_index_target *itarget,
+								int path_length, Datum *path_datums);
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+										  pgpa_advice_target *target,
+										  bool *rids_used);
+
+/*
+ * Parse a user-provided advice string.
+ *
+ * If parsing is successful, returns a representation of the resulting syntax
+ * tree.
+ */
+Datum
+pg_parse_advice(PG_FUNCTION_ARGS)
+{
+	text	   *advice_string = PG_GETARG_TEXT_PP(0);
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	List	   *advice_items;
+	char	   *error;
+	int			item_count = 0;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Initial parsing. */
+	advice_items = pgpa_parse(text_to_cstring(advice_string), &error);
+	if (advice_items == NULL)
+	{
+		Assert(error != NULL);
+		ereport(ERROR,
+				errcode(ERRCODE_SYNTAX_ERROR),
+				errmsg("could not parse query advice: %s", error));
+	}
+
+	/* Loop over all advice items. */
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		Datum		values[PG_PARSE_ADVICE_COLUMNS];
+		bool		nulls[PG_PARSE_ADVICE_COLUMNS];
+		Datum		path_datums[2];
+		ArrayType  *path;
+		int			target_count = 0;
+
+		/* Emit a top-level record for each advice item. */
+		path_datums[0] = Int32GetDatum(++item_count);
+		path = construct_array_builtin(path_datums, 1, INT4OID);
+		values[0] = PointerGetDatum(path);
+		values[1] = CStringGetTextDatum(pgpa_cstring_advice_tag(item->tag));
+		for (int i = 0; i < PG_PARSE_ADVICE_COLUMNS; ++i)
+			nulls[i] = (i >= 2);
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+
+		/* Recursively describe targets (and any substructure). */
+		foreach_ptr(pgpa_advice_target, target, item->targets)
+		{
+			path_datums[1] = Int32GetDatum(++target_count);
+			pgpa_describe_target(rsinfo, target, 2, path_datums);
+		}
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+	switch (advice_tag)
+	{
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_FOREIGN_JOIN:
+			return "FOREIGN_JOIN";
+		case PGPA_TAG_GATHER:
+			return "GATHER";
+		case PGPA_TAG_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPA_TAG_HASH_JOIN:
+			return "HASH_JOIN";
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_TAG_INDEX_SCAN:
+			return "INDEX_SCAN";
+		case PGPA_TAG_JOIN_ORDER:
+			return "JOIN_ORDER";
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case PGPA_TAG_NO_GATHER:
+			return "NO_GATHER";
+		case PGPA_TAG_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+		case PGPA_TAG_SEQ_SCAN:
+			return "SEQ_SCAN";
+		case PGPA_TAG_TID_SCAN:
+			return "TID_SCAN";
+	}
+
+	Assert(false);
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and reurn an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+	*fail = false;
+
+	switch (tag[0])
+	{
+		case 'b':
+			if (strcmp(tag, "bitmap_heap_scan") == 0)
+				return PGPA_TAG_BITMAP_HEAP_SCAN;
+			break;
+		case 'f':
+			if (strcmp(tag, "foreign_join") == 0)
+				return PGPA_TAG_FOREIGN_JOIN;
+			break;
+		case 'g':
+			if (strcmp(tag, "gather") == 0)
+				return PGPA_TAG_GATHER;
+			if (strcmp(tag, "gather_merge") == 0)
+				return PGPA_TAG_GATHER_MERGE;
+			break;
+		case 'h':
+			if (strcmp(tag, "hash_join") == 0)
+				return PGPA_TAG_HASH_JOIN;
+			break;
+		case 'i':
+			if (strcmp(tag, "index_scan") == 0)
+				return PGPA_TAG_INDEX_SCAN;
+			if (strcmp(tag, "index_only_scan") == 0)
+				return PGPA_TAG_INDEX_ONLY_SCAN;
+			break;
+		case 'j':
+			if (strcmp(tag, "join_order") == 0)
+				return PGPA_TAG_JOIN_ORDER;
+			break;
+		case 'm':
+			if (strcmp(tag, "merge_join_materialize") == 0)
+				return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+			if (strcmp(tag, "merge_join_plain") == 0)
+				return PGPA_TAG_MERGE_JOIN_PLAIN;
+			break;
+		case 'n':
+			if (strcmp(tag, "nested_loop_materialize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+			if (strcmp(tag, "nested_loop_memoize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+			if (strcmp(tag, "nested_loop_plain") == 0)
+				return PGPA_TAG_NESTED_LOOP_PLAIN;
+			if (strcmp(tag, "no_gather") == 0)
+				return PGPA_TAG_NO_GATHER;
+			break;
+		case 'p':
+			if (strcmp(tag, "partitionwise") == 0)
+				return PGPA_TAG_PARTITIONWISE;
+			break;
+		case 's':
+			if (strcmp(tag, "semijoin_non_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+			if (strcmp(tag, "semijoin_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_UNIQUE;
+			if (strcmp(tag, "seq_scan") == 0)
+				return PGPA_TAG_SEQ_SCAN;
+			break;
+		case 't':
+			if (strcmp(tag, "tid_scan") == 0)
+				return PGPA_TAG_TID_SCAN;
+			break;
+	}
+
+	/* didn't work out */
+	*fail = true;
+
+	/* return an arbitrary value to unwind the call stack */
+	return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Helper function for pg_parse_advice, used to describe top-level targets
+ * and any nested targets.
+ */
+static void
+pgpa_describe_target(ReturnSetInfo *rsinfo, pgpa_advice_target *target,
+					 int path_length, Datum *path_datums)
+{
+	Datum		values[PG_PARSE_ADVICE_COLUMNS];
+	bool		nulls[PG_PARSE_ADVICE_COLUMNS] = {0};
+	ArrayType  *path;
+	Datum	   *child_path_datums;
+	int			child_count = 0;
+
+	/* We always have a path, which the caller provides. */
+	path = construct_array_builtin(path_datums, path_length, INT4OID);
+	values[0] = PointerGetDatum(path);
+
+	/* Only advice items have tags, not individual targets. */
+	nulls[1] = true;
+
+	/* Prepare path array for any children */
+	child_path_datums = palloc0_array(Datum, path_length + 1);
+	memcpy(child_path_datums, path_datums, path_length * sizeof(Datum));
+
+	/* Relation identifiers require very different handling vs. sublists. */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		nulls[2] = true;
+
+		/* shouldn't ever be NULL, but just in case */
+		if (target->rid.alias_name != NULL)
+			values[3] = CStringGetTextDatum(target->rid.alias_name);
+		else
+			nulls[3] = true;
+
+		/* can't be NULL */
+		values[4] = Int32GetDatum(target->rid.occurrence);
+
+		/* can be NULL if not a partition or no schema given */
+		if (target->rid.partnsp != NULL)
+			values[5] = CStringGetTextDatum(target->rid.partnsp);
+		else
+			nulls[5] = true;
+
+		/* can be NULL if not a partition */
+		if (target->rid.partrel != NULL)
+			values[6] = CStringGetTextDatum(target->rid.partrel);
+		else
+			nulls[6] = true;
+
+		/* can be NULL at top query level */
+		if (target->rid.plan_name != NULL)
+			values[7] = CStringGetTextDatum(target->rid.plan_name);
+		else
+			nulls[7] = true;
+
+		/* output the row */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+
+		/* recurse into index target if any */
+		if (target->itarget != NULL)
+		{
+			child_path_datums[path_length] = Int32GetDatum(++child_count);
+			pgpa_describe_index(rsinfo, target->itarget, path_length + 1,
+								child_path_datums);
+		}
+	}
+	else
+	{
+		/* operation is a function of target type */
+		switch (target->ttype)
+		{
+			case PGPA_TARGET_IDENTIFIER:
+				Assert(false);	/* handled above */
+				values[2] = CStringGetTextDatum("BUG");
+				break;
+			case PGPA_TARGET_ORDERED_LIST:
+				values[2] = CStringGetTextDatum("()");
+				break;
+			case PGPA_TARGET_UNORDERED_LIST:
+				values[2] = CStringGetTextDatum("{}");
+				break;
+		}
+
+		/* everything but path and target will be null */
+		for (int i = 0; i < PG_PARSE_ADVICE_COLUMNS; ++i)
+			nulls[i] = (i != 0 && i != 2);
+
+		/* output the row */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+
+		/* recurse into child targets */
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			child_path_datums[path_length] = Int32GetDatum(++child_count);
+			pgpa_describe_target(rsinfo, child_target, path_length + 1,
+								 child_path_datums);
+		}
+	}
+}
+
+/*
+ * Helper function for pg_parse_advice, used to describe index targets.
+ */
+static void
+pgpa_describe_index(ReturnSetInfo *rsinfo, pgpa_index_target *itarget,
+					int path_length, Datum *path_datums)
+{
+	Datum		values[PG_PARSE_ADVICE_COLUMNS];
+	bool		nulls[PG_PARSE_ADVICE_COLUMNS] = {0};
+	ArrayType  *path;
+
+	/* We always have a path, which the caller provides. */
+	path = construct_array_builtin(path_datums, path_length, INT4OID);
+	values[0] = PointerGetDatum(path);
+
+	/* Only advice items have tags, not individual targets. */
+	nulls[1] = true;
+
+	/* Relation identifiers require very different handling vs. sublists. */
+	if (itarget->itype == PGPA_INDEX_NAME)
+	{
+		/* operator, alias_name, and occurence number are null */
+		nulls[2] = true;
+		nulls[3] = true;
+		nulls[4] = true;
+
+		/* indnamespace might be NULL */
+		if (itarget->indnamespace != NULL)
+			values[5] = CStringGetTextDatum(itarget->indnamespace);
+		else
+			nulls[5] = true;
+
+		/* indname should never be NULL */
+		values[6] = CStringGetTextDatum(itarget->indname);
+
+		/* plan_name is NULL */
+		nulls[7] = true;
+
+		/* output the row */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+	else
+	{
+		Datum	   *child_path_datums;
+		int			child_count = 0;
+
+		/* operation is a function of target type */
+		switch (itarget->itype)
+		{
+			case PGPA_INDEX_NAME:
+				Assert(false);	/* handled above */
+				values[2] = CStringGetTextDatum("BUG");
+				break;
+			case PGPA_INDEX_AND:
+				values[2] = CStringGetTextDatum("&&");
+				break;
+			case PGPA_INDEX_OR:
+				values[2] = CStringGetTextDatum("||");
+				break;
+		}
+
+		/* everything but path and target will be null */
+		for (int i = 0; i < PG_PARSE_ADVICE_COLUMNS; ++i)
+			nulls[i] = (i != 0 && i != 2);
+
+		/* output the row */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+
+		/* prepare path array for children */
+		child_path_datums = palloc0_array(Datum, path_length + 1);
+		memcpy(child_path_datums, path_datums, path_length * sizeof(Datum));
+
+		/* now recurse into children */
+		foreach_ptr(pgpa_index_target, child_target, itarget->children)
+		{
+			child_path_datums[path_length] = Int32GetDatum(++child_count);
+			pgpa_describe_index(rsinfo, child_target, path_length + 1,
+								child_path_datums);
+		}
+	}
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		bool		first = true;
+		char	   *delims;
+
+		if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+			delims = "{}";
+		else
+			delims = "()";
+
+		appendStringInfoChar(str, delims[0]);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_advice_target(str, child_target);
+		}
+		appendStringInfoChar(str, delims[1]);
+	}
+	else
+	{
+		const char *rt_identifier;
+
+		rt_identifier = pgpa_identifier_string(&target->rid);
+		appendStringInfoString(str, rt_identifier);
+	}
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+	if (itarget->itype != PGPA_INDEX_NAME)
+	{
+		bool		first = true;
+
+		if (itarget->itype == PGPA_INDEX_AND)
+			appendStringInfoString(str, "&&(");
+		else
+			appendStringInfoString(str, "||(");
+
+		foreach_ptr(pgpa_index_target, child_target, itarget->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_index_target(str, child_target);
+		}
+		appendStringInfoChar(str, ')');
+	}
+	else
+	{
+		if (itarget->indnamespace != NULL)
+			appendStringInfo(str, "%s.",
+							 quote_identifier(itarget->indnamespace));
+		appendStringInfoString(str, quote_identifier(itarget->indname));
+	}
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+	if (i1->itype != i2->itype)
+		return false;
+
+	if (i1->itype == PGPA_INDEX_NAME)
+	{
+		/* indnamespace can be NULL, and two NULL values are equal */
+		if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+			(i1->indnamespace == NULL || i2->indnamespace == NULL ||
+			 strcmp(i1->indnamespace, i2->indnamespace) != 0))
+			return false;
+		if (strcmp(i1->indname, i2->indname) != 0)
+			return false;
+	}
+	else
+	{
+		int			i1_length = list_length(i1->children);
+
+		if (i1_length != list_length(i2->children))
+			return false;
+		for (int n = 0; n < i1_length; ++n)
+		{
+			pgpa_index_target *c1 = list_nth(i1->children, n);
+			pgpa_index_target *c2 = list_nth(i2->children, n);
+
+			if (!pgpa_index_targets_equal(c1, c2))
+				return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+	/* For non-identifiers, check all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (pgpa_identifier_matches_target(rid, child_target))
+				return true;
+		}
+		return false;
+	}
+
+	if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+		return false;
+	if (rid->occurrence != target->rid.occurrence)
+		return false;
+
+	/*
+	 * The identifier must specify a schema, but the target may leave the
+	 * schema NULL to match anything.
+	 */
+	if (target->rid.partnsp != NULL &&
+		strcmp(rid->partnsp, target->rid.partnsp) != 0)
+		return false;
+
+
+	/*
+	 * These fields can be NULL on either side, but NULL only matches another
+	 * NULL.
+	 */
+	if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+		return false;
+	if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+		return false;
+
+	return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target)
+{
+	bool		all_rids_used = true;
+	bool		any_rids_used = false;
+	bool		all_targets_used;
+	bool	   *rids_used = palloc0_array(bool, nrids);
+
+	all_targets_used =
+		pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+	for (int i = 0; i < nrids; ++i)
+	{
+		if (rids_used[i])
+			any_rids_used = true;
+		else
+			all_rids_used = false;
+	}
+
+	if (all_rids_used)
+	{
+		if (all_targets_used)
+			return PGPA_ITM_EQUAL;
+		else
+			return PGPA_ITM_KEYS_ARE_SUBSET;
+	}
+	else
+	{
+		if (all_targets_used)
+			return PGPA_ITM_TARGETS_ARE_SUBSET;
+		else if (any_rids_used)
+			return PGPA_ITM_INTERSECTING;
+		else
+			return PGPA_ITM_DISJOINT;
+	}
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target, bool *rids_used)
+{
+	bool		result = false;
+
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		result = true;
+
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+											   rids_used))
+				result = false;
+		}
+	}
+	else
+	{
+		for (int i = 0; i < nrids; ++i)
+		{
+			if (pgpa_identifier_matches_target(&rids[i], target))
+			{
+				rids_used[i] = true;
+				result = true;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
new file mode 100644
index 00000000000..f6fe730a4d4
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -0,0 +1,204 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ *	  abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+	PGPA_TARGET_IDENTIFIER,		/* relation identifier */
+	PGPA_TARGET_ORDERED_LIST,	/* (item ...) */
+	PGPA_TARGET_UNORDERED_LIST	/* {item ...} */
+} pgpa_target_type;
+
+/*
+ * When an advice item describes a bitmap index scan, it may need to describe
+ * the use of multiple indexes.
+ */
+typedef enum
+{
+	PGPA_INDEX_NAME,			/* index schema + name */
+	PGPA_INDEX_AND,				/* &&(item ...) */
+	PGPA_INDEX_OR				/* ||(item ...) */
+} pgpa_index_type;
+
+/*
+ * An index specification. We use this for INDEX_SCAN, INDEX_ONLY_SCAN,
+ * and BITMAP_HEAP_SCAN advice, but in the former two cases, the target must
+ * be of type PGPA_INDEX_NAME.
+ */
+typedef struct pgpa_index_target
+{
+	pgpa_index_type itype;
+
+	/* Index schem and name, when itype == PGPA_INDEX_NAME */
+	char	   *indnamespace;
+	char	   *indname;
+
+	/* List of pgpa_index_target objects, when itype != PGPA_INDEX_NAME */
+	List	   *children;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+	pgpa_target_type ttype;
+
+	/*
+	 * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+	 *
+	 * All identifiers must have an alias name and an occurrence number; the
+	 * remaining fields can be NULL. Note that it's possible to specify a
+	 * partition name without a partition schema, but not the reverse.
+	 */
+	pgpa_identifier rid;
+
+	/*
+	 * This field is set when ttype is PPGA_TARGET_IDENTIFIER and the advice
+	 * tag is PGPA_TAG_INDEX_SCAN, PGPA_TAG_INDEX_ONLY_SCAN, or
+	 * PGPA_TAG_BITMAP_HEAP_SCAN.
+	 */
+	pgpa_index_target *itarget;
+
+	/*
+	 * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+	 * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+	 */
+	List	   *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_FOREIGN_JOIN,
+	PGPA_TAG_GATHER,
+	PGPA_TAG_GATHER_MERGE,
+	PGPA_TAG_HASH_JOIN,
+	PGPA_TAG_INDEX_ONLY_SCAN,
+	PGPA_TAG_INDEX_SCAN,
+	PGPA_TAG_JOIN_ORDER,
+	PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+	PGPA_TAG_MERGE_JOIN_PLAIN,
+	PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+	PGPA_TAG_NESTED_LOOP_MEMOIZE,
+	PGPA_TAG_NESTED_LOOP_PLAIN,
+	PGPA_TAG_NO_GATHER,
+	PGPA_TAG_PARTITIONWISE,
+	PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+	PGPA_TAG_SEMIJOIN_UNIQUE,
+	PGPA_TAG_SEQ_SCAN,
+	PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+	pgpa_advice_tag_type tag;
+	List	   *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by an
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifeirs and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifeirs and targets were
+ * found.
+ */
+typedef enum
+{
+	PGPA_ITM_EQUAL,
+	PGPA_ITM_KEYS_ARE_SUBSET,
+	PGPA_ITM_TARGETS_ARE_SUBSET,
+	PGPA_ITM_INTERSECTING,
+	PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int	pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+					   char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner,
+						 const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int	pgpa_yyparse(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+										   pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+												   pgpa_identifier *rids,
+												   pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+									 pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+									  pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+									 pgpa_index_target *itarget);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_collector.c b/contrib/pg_plan_advice/pgpa_collector.c
new file mode 100644
index 00000000000..3fa045a0b3c
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.c
@@ -0,0 +1,626 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.c
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgpa_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgpa_collected_advice;
+
+/*
+ * A bunch of pointers to pgpa_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgpa_local_advice_chunk
+{
+	pgpa_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgpa_local_advice_chunk;
+
+/*
+ * Information about all of the pgpa_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgpa_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgpa_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgpa_local_advice_chunk **chunks;
+} pgpa_local_advice;
+
+/*
+ * Just like pgpa_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgpa_shared_advice_chunk;
+
+/*
+ * Just like pgpa_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgpa_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgpa_local_advice *local_collector = NULL;
+static pgpa_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgpa_collected_advice *pgpa_make_collected_advice(Oid userid,
+														 Oid dbid,
+														 uint64 queryId,
+														 TimestampTz timestamp,
+														 const char *query_string,
+														 const char *advice_string,
+														 dsa_area *area,
+														 dsa_pointer *result);
+static void pgpa_store_local_advice(pgpa_collected_advice *ca);
+static void pgpa_trim_local_advice(int limit);
+static void pgpa_store_shared_advice(dsa_pointer ca_pointer);
+static void pgpa_trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgpa_collected_advice */
+static inline const char *
+query_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgpa_collected_advice */
+static inline const char *
+advice_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pgpa_collect_advice(uint64 queryId, const char *query_string,
+					const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_plan_advice_local_collection_limit > 0)
+	{
+		pgpa_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+		ca = pgpa_make_collected_advice(userid, dbid, queryId, now,
+										query_string, advice_string,
+										NULL, NULL);
+		pgpa_store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_plan_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_plan_advice_dsa_area();
+		dsa_pointer ca_pointer;
+
+		pgpa_make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string, area,
+								   &ca_pointer);
+		pgpa_store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgpa_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgpa_collected_advice *
+pgpa_make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+						   TimestampTz timestamp,
+						   const char *query_string,
+						   const char *advice_string,
+						   dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgpa_collected_advice *ca;
+
+	total_length = offsetof(pgpa_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = GetUserId();
+	ca->dbid = MyDatabaseId;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pg_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+pgpa_store_local_advice(pgpa_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgpa_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgpa_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number > la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgpa_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgpa_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_local_advice(pg_plan_advice_local_collection_limit);
+}
+
+/*
+ * Add a pg_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_plan_advice DSA area
+ * and should point to an object of type pgpa_collected_advice.
+ */
+static void
+pgpa_store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	pgpa_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgpa_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgpa_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_shared_advice(area, pg_plan_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_local_advice(int limit)
+{
+	pgpa_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgpa_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_shared_advice(dsa_area *area, int limit)
+{
+	pgpa_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	pgpa_trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice *sa = shared_collector;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_plan_advice/pgpa_collector.h b/contrib/pg_plan_advice/pgpa_collector.h
new file mode 100644
index 00000000000..b6e746a06d7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.h
@@ -0,0 +1,18 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.h
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_COLLECTOR_H
+#define PGPA_COLLECTOR_H
+
+extern void pgpa_collect_advice(uint64 queryId, const char *query_string,
+								const char *advice_string);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_identifier.c b/contrib/pg_plan_advice/pgpa_identifier.c
new file mode 100644
index 00000000000..2fa8075d66e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.c
@@ -0,0 +1,476 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ *	  create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted.  Identifier-style escaping is applied to alias_name and
+ * subquery_name.  Whenever we include child_table_name, we always
+ * schema-qualified name, but writing their own plan advice are not required
+ * to do so.  Identifier-style escaping is applied to the schema and to the
+ * relation names separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+									  List *appinfos);
+static int	pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+								   SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+	const char *result;
+
+	Assert(rid->alias_name != NULL);
+	result = quote_identifier(rid->alias_name);
+
+	Assert(rid->occurrence >= 0);
+	if (rid->occurrence > 1)
+		result = psprintf("%s#%d", result, rid->occurrence);
+
+	if (rid->partrel != NULL)
+	{
+		if (rid->partnsp == NULL)
+			result = psprintf("%s/%s", result,
+							  quote_identifier(rid->partnsp));
+		else
+			result = psprintf("%s/%s.%s", result,
+							  quote_identifier(rid->partnsp),
+							  quote_identifier(rid->partrel));
+	}
+
+	if (rid->plan_name != NULL)
+		result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+	return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+							   pgpa_identifier *rid)
+{
+	Index		top_rti = rti;
+	int			occurrence = 1;
+	RangeTblEntry *rte;
+	RangeTblEntry *top_rte;
+	char	   *partnsp = NULL;
+	char	   *partrel = NULL;
+
+	/*
+	 * If this is a child RTE, find the topmost parent that is still of type
+	 * RTE_RELATION. We do this because we identify children of partitioned
+	 * tables by the name of the child table, but subqueries can also have
+	 * child rels and we don't care about those here.
+	 */
+	for (;;)
+	{
+		AppendRelInfo *appinfo;
+		RangeTblEntry *parent_rte;
+
+		/* append_rel_array can be NULL if there are no children */
+		if (root->append_rel_array == NULL ||
+			(appinfo = root->append_rel_array[top_rti]) == NULL)
+			break;
+
+		parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+		if (parent_rte->rtekind != RTE_RELATION)
+			break;
+
+		top_rti = appinfo->parent_relid;
+	}
+
+	/* Get the range table entries for the RTI and top RTI. */
+	rte = planner_rt_fetch(rti, root);
+	top_rte = planner_rt_fetch(top_rti, root);
+	Assert(rte->rtekind != RTE_JOIN);
+	Assert(top_rte->rtekind != RTE_JOIN);
+
+	/* Work out the correct occurrence number. */
+	for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+		AppendRelInfo *appinfo;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 *
+		 * NB: append_rel_array can be NULL if there are no children
+		 */
+		if (root->append_rel_array != NULL &&
+			(appinfo = root->append_rel_array[prior_rti]) != NULL)
+		{
+			RangeTblEntry *parent_rte;
+
+			parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+			if (parent_rte->rtekind == RTE_RELATION)
+				continue;
+		}
+
+		/* Skip NULL entries and joins. */
+		prior_rte = planner_rt_fetch(prior_rti, root);
+		if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	/* If this is a child table, get the schema and relation names. */
+	if (rti != top_rti)
+	{
+		partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+		partrel = get_rel_name(rte->relid);
+	}
+
+	/* OK, we have all the answers we need. Return them to the caller. */
+	rid->alias_name = top_rte->eref->aliasname;
+	rid->occurrence = occurrence;
+	rid->partnsp = partnsp;
+	rid->partrel = partrel;
+	rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+								   pgpa_identifier *rids)
+{
+	int			count = 0;
+	int			rti = -1;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = planner_rt_fetch(rti, root);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+		pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+	}
+
+	Assert(count > 0);
+	return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+	Index		rtable_length = list_length(pstmt->rtable);
+	pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+	Index	   *top_rti_map;
+	int			rtinfoindex = 0;
+	SubPlanRTInfo *rtinfo = NULL;
+	SubPlanRTInfo *nextrtinfo = NULL;
+
+	/*
+	 * Account for relations addded by inheritance expansion of partitioned
+	 * tables.
+	 */
+	top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+										  pstmt->appendRelations);
+
+	/*
+	 * When we begin iterating, we're processing the portion of the range
+	 * table that originated from the top-level PlannerInfo, so subrtinfo is
+	 * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+	 * portion of the range table we are processing. nextrtinfo is always the
+	 * SubPlanRTInfo that follows the current one, if any, so when we're
+	 * processing the top-level query's portion of the range table, the next
+	 * SubPlanRTInfo is the very first one.
+	 */
+	if (pstmt->subrtinfos != NULL)
+		nextrtinfo = linitial(pstmt->subrtinfos);
+
+	/* Main loop over the range table. */
+	for (Index rti = 1; rti <= rtable_length; rti++)
+	{
+		const char *plan_name;
+		Index		top_rti;
+		RangeTblEntry *rte;
+		RangeTblEntry *top_rte;
+		char	   *partnsp = NULL;
+		char	   *partrel = NULL;
+		int			occurrence;
+		pgpa_identifier *rid;
+
+		/*
+		 * Advance to the next SubPlanRTInfo, if it's time to do that.
+		 *
+		 * This loop probably shouldn't ever iterate more than once, because
+		 * that would imply that a subquery was planned but added nothing to
+		 * the range table; but let's be defensive and assume it can happen.
+		 */
+		while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+		{
+			rtinfo = nextrtinfo;
+			if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+				nextrtinfo = NULL;
+			else
+				nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+		}
+
+		/* Fetch the range table entry, if any. */
+		rte = rt_fetch(rti, pstmt->rtable);
+
+		/*
+		 * We can't and don't need to identify null entries, and we don't want
+		 * to identify join entries.
+		 */
+		if (rte == NULL || rte->rtekind == RTE_JOIN)
+			continue;
+
+		/*
+		 * If this is not a relation added by partitioned table expansion,
+		 * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+		 * we need the information for the top RTI/RTE, and must also fetch
+		 * the partition schema and name.
+		 */
+		top_rti = top_rti_map[rti - 1];
+		if (rti == top_rti)
+			top_rte = rte;
+		else
+		{
+			top_rte = rt_fetch(top_rti, pstmt->rtable);
+			partnsp =
+				get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+			partrel = get_rel_name(rte->relid);
+		}
+
+		/* Compute the correct occurrence number. */
+		occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+											rtinfo, top_rti);
+
+		/* Get the name of the current plan (NULL for toplevel query). */
+		plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+		/* Save all the details we've derived. */
+		rid = &result[rti - 1];
+		rid->alias_name = top_rte->eref->aliasname;
+		rid->occurrence = occurrence;
+		rid->partnsp = partnsp;
+		rid->partrel = partrel;
+		rid->plan_name = plan_name;
+	}
+
+	return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid)
+{
+	Index		result = 0;
+
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+	{
+		pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+		/* If there's no identifier for this RTI, skip it. */
+		if (rti_rid->alias_name == NULL)
+			continue;
+
+		/*
+		 * If it matches, return this RTI. As usual, an omitted partition
+		 * schema matches anything, but partition and plan names must either
+		 * match exactly or be omitted on both sides.
+		 */
+		if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+			rid->occurrence == rti_rid->occurrence &&
+			(rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+			 strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+			strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+			strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+		{
+			if (result != 0)
+			{
+				/* Multiple matches were found. */
+				return 0;
+			}
+			result = rti;
+		}
+	}
+
+	return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+	Index	   *top_rti_map = palloc0_array(Index, rtable_length);
+
+	/* Initially, make every RTI point to itself. */
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+		top_rti_map[rti - 1] = rti;
+
+	/* Update the map for each AppendRelInfo object. */
+	foreach_node(AppendRelInfo, appinfo, appinfos)
+	{
+		Index		parent_rti = appinfo->parent_relid;
+		RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+		/* If the parent is not RTE_RELATION, ignore this entry. */
+		if (parent_rte->rtekind != RTE_RELATION)
+			continue;
+
+		/*
+		 * Map the child to wherever we mapped the parent. Parents always
+		 * precede their children in the AppendRelInfo list, so this should
+		 * work out.
+		 */
+		top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+	}
+
+	return top_rti_map;
+}
+
+/*
+ * Find the occurence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+					   SubPlanRTInfo *rtinfo, Index rti)
+{
+	Index		rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+	int			occurrence = 1;
+	RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+	for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 */
+		if (top_rti_map[prior_rti - 1] != prior_rti)
+			break;
+
+		/* Skip joins. */
+		prior_rte = rt_fetch(prior_rti, rtable);
+		if (prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	return occurrence;
+}
diff --git a/contrib/pg_plan_advice/pgpa_identifier.h b/contrib/pg_plan_advice/pgpa_identifier.h
new file mode 100644
index 00000000000..b000d2b7081
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ *	  create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+	const char *alias_name;
+	int			occurrence;
+	const char *partnsp;
+	const char *partrel;
+	const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+	if (a == b)
+		return true;
+	else if (a == NULL || b == NULL)
+		return false;
+	else
+		return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+										   pgpa_identifier *rid);
+extern int	pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+											   Bitmapset *relids,
+											   pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+											  pgpa_identifier *rt_identifiers,
+											  pgpa_identifier *rid);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
new file mode 100644
index 00000000000..28618764d86
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -0,0 +1,615 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	Plan	   *outer_subplan;
+	ElidedNode *outer_elided_node;
+	bool		outer_beneath_any_gather;
+	pgpa_join_strategy *strategy;
+	Plan	  **inner_subplans;
+	ElidedNode **inner_elided_nodes;
+	pgpa_join_unroller **inner_unrollers;
+	bool	   *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+											  Plan *plan,
+											  Plan **realouter,
+											  Plan **realinner,
+											  ElidedNode **elidedrealouter,
+											  ElidedNode **elidedrealinner,
+											  bool *found_any_outer_gather,
+											  bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+										   bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+									ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+	pgpa_join_unroller *join_unroller;
+
+	join_unroller = palloc0_object(pgpa_join_unroller);
+	join_unroller->nallocated = 4;
+	join_unroller->strategy =
+		palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+	join_unroller->inner_subplans =
+		palloc_array(Plan *, join_unroller->nallocated);
+	join_unroller->inner_elided_nodes =
+		palloc_array(ElidedNode *, join_unroller->nallocated);
+	join_unroller->inner_unrollers =
+		palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+	join_unroller->inner_beneath_any_gather =
+		palloc_array(bool, join_unroller->nallocated);
+
+	return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+				 bool beneath_any_gather,
+				 pgpa_join_unroller *join_unroller,
+				 pgpa_join_unroller **outer_join_unroller,
+				 pgpa_join_unroller **inner_join_unroller)
+{
+	pgpa_join_strategy strategy;
+	Plan	   *realinner,
+			   *realouter;
+	ElidedNode *elidedinner,
+			   *elidedouter;
+	int			n;
+	bool		found_any_outer_gather = false;
+	bool		found_any_inner_gather = false;
+
+	Assert(join_unroller != NULL);
+
+	/*
+	 * We need to pass the join_unroller object down through certain types of
+	 * plan nodes -- anything that's considered part of the join strategy, and
+	 * any other nodes that can occur in a join tree despite not being scans
+	 * or joins.
+	 *
+	 * This includes:
+	 *
+	 * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+	 * strategy,
+	 *
+	 * (2) Gather and Gather Merge nodes, which can occur at any point in the
+	 * join tree where the planner decided to initiate parallelism,
+	 *
+	 * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+	 * or GatherMerge,
+	 *
+	 * (4) Agg and Unique nodes, which can occur when we decide to make the
+	 * nullable side of a semijoin unique and then join the result, and
+	 *
+	 * (5) Result nodes with children, which can be added either to project to
+	 * enforce a one-time filter (but Result nodes without children are
+	 * degenerate scans or joins).
+	 */
+	if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+		|| IsA(plan, Gather) || IsA(plan, GatherMerge)
+		|| is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+		|| is_result_node_with_child(plan))
+	{
+		*outer_join_unroller = join_unroller;
+		return;
+	}
+
+	/*
+	 * Since we've already handled nodes that require pass-through treatment,
+	 * this should be an unrollable join.
+	 */
+	strategy = pgpa_decompose_join(walker, plan,
+								   &realouter, &realinner,
+								   &elidedouter, &elidedinner,
+								   &found_any_outer_gather,
+								   &found_any_inner_gather);
+
+	/* If our workspace is full, expand it. */
+	if (join_unroller->nused >= join_unroller->nallocated)
+	{
+		join_unroller->nallocated *= 2;
+		join_unroller->strategy =
+			repalloc_array(join_unroller->strategy,
+						   pgpa_join_strategy,
+						   join_unroller->nallocated);
+		join_unroller->inner_subplans =
+			repalloc_array(join_unroller->inner_subplans,
+						   Plan *,
+						   join_unroller->nallocated);
+		join_unroller->inner_elided_nodes =
+			repalloc_array(join_unroller->inner_elided_nodes,
+						   ElidedNode *,
+						   join_unroller->nallocated);
+		join_unroller->inner_beneath_any_gather =
+			repalloc_array(join_unroller->inner_beneath_any_gather,
+						   bool,
+						   join_unroller->nallocated);
+		join_unroller->inner_unrollers =
+			repalloc_array(join_unroller->inner_unrollers,
+						   pgpa_join_unroller *,
+						   join_unroller->nallocated);
+	}
+
+	/*
+	 * Since we're flattening outer-deep join trees, it follows that if the
+	 * outer side is still an unrollable join, it should be unrolled into this
+	 * same object. Otherwise, we've reached the limit of what we can unroll
+	 * into this object and must remember the outer side as the final outer
+	 * subplan.
+	 */
+	if (elidedouter == NULL && pgpa_is_join(realouter))
+		*outer_join_unroller = join_unroller;
+	else
+	{
+		join_unroller->outer_subplan = realouter;
+		join_unroller->outer_elided_node = elidedouter;
+		join_unroller->outer_beneath_any_gather =
+			beneath_any_gather || found_any_outer_gather;
+	}
+
+	/*
+	 * Store the inner subplan. If it's an unrollable join, it needs to be
+	 * flattened in turn, but into a new unroller object, not this one.
+	 */
+	n = join_unroller->nused++;
+	join_unroller->strategy[n] = strategy;
+	join_unroller->inner_subplans[n] = realinner;
+	join_unroller->inner_elided_nodes[n] = elidedinner;
+	join_unroller->inner_beneath_any_gather[n] =
+		beneath_any_gather || found_any_inner_gather;
+	if (elidedinner == NULL && pgpa_is_join(realinner))
+		*inner_join_unroller = pgpa_create_join_unroller();
+	else
+		*inner_join_unroller = NULL;
+	join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+						 pgpa_join_unroller *join_unroller)
+{
+	pgpa_unrolled_join *ujoin;
+	int			i;
+
+	/*
+	 * We shouldn't have gone even so far as to create a join unroller unless
+	 * we found at least one unrollable join.
+	 */
+	Assert(join_unroller->nused > 0);
+
+	/* Allocate result structures. */
+	ujoin = palloc0_object(pgpa_unrolled_join);
+	ujoin->ninner = join_unroller->nused;
+	ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+	ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+	/* Handle the outermost join. */
+	ujoin->outer.plan = join_unroller->outer_subplan;
+	ujoin->outer.elided_node = join_unroller->outer_elided_node;
+	ujoin->outer.scan =
+		pgpa_build_scan(walker, ujoin->outer.plan,
+						ujoin->outer.elided_node,
+						join_unroller->outer_beneath_any_gather,
+						true);
+
+	/*
+	 * We want the joins from the deepest part of the plan tree to appear
+	 * first in the result object, but the join unroller adds them in exactly
+	 * the reverse of that order, so we need to flip the order of the arrays
+	 * when constructing the final result.
+	 */
+	for (i = 0; i < join_unroller->nused; ++i)
+	{
+		int			k = join_unroller->nused - i - 1;
+
+		/* Copy strategy, Plan, and ElidedNode. */
+		ujoin->strategy[i] = join_unroller->strategy[k];
+		ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+		ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+		/*
+		 * Fill in remaining details, using either the nested join unroller,
+		 * or by deriving them from the plan and elided nodes.
+		 */
+		if (join_unroller->inner_unrollers[k] != NULL)
+			ujoin->inner[i].unrolled_join =
+				pgpa_build_unrolled_join(walker,
+										 join_unroller->inner_unrollers[k]);
+		else
+			ujoin->inner[i].scan =
+				pgpa_build_scan(walker, ujoin->inner[i].plan,
+								ujoin->inner[i].elided_node,
+								join_unroller->inner_beneath_any_gather[i],
+								true);
+	}
+
+	return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+	pfree(join_unroller->strategy);
+	pfree(join_unroller->inner_subplans);
+	pfree(join_unroller->inner_elided_nodes);
+	pfree(join_unroller->inner_unrollers);
+	pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort note on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any correspoding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+					Plan **realouter, Plan **realinner,
+					ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+					bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+	PlannedStmt *pstmt = walker->pstmt;
+	JoinType	jointype = ((Join *) plan)->jointype;
+	Plan	   *outerplan = plan->lefttree;
+	Plan	   *innerplan = plan->righttree;
+	ElidedNode *elidedouter;
+	ElidedNode *elidedinner;
+	pgpa_join_strategy strategy;
+	bool		uniqueouter;
+	bool		uniqueinner;
+
+	elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+	elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+	*found_any_outer_gather = false;
+	*found_any_inner_gather = false;
+
+	switch (nodeTag(plan))
+	{
+		case T_MergeJoin:
+
+			/*
+			 * The planner may have chosen to place a Material node on the
+			 * inner side of the MergeJoin; if this is present, we record it
+			 * as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+			}
+			else
+				strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+			/*
+			 * For a MergeJoin, either the outer or the inner subplan, or
+			 * both, may have needed to be sorted; we must disregard any Sort
+			 * or IncrementalSort node to find the real inner or outer
+			 * subplan.
+			 */
+			if (elidedouter == NULL && is_sorting_plan(outerplan))
+				elidedouter = pgpa_descend_node(pstmt, &outerplan);
+			if (elidedinner == NULL && is_sorting_plan(innerplan))
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			break;
+
+		case T_NestLoop:
+
+			/*
+			 * The planner may have chosen to place a Material or Memoize node
+			 * on the inner side of the NestLoop; if this is present, we
+			 * record it as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+			}
+			else if (elidedinner == NULL && IsA(innerplan, Memoize))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+			}
+			else
+				strategy = JSTRAT_NESTED_LOOP_PLAIN;
+			break;
+
+		case T_HashJoin:
+
+			/*
+			 * The inner subplan of a HashJoin is always a Hash node; the real
+			 * inner subplan is the Hash node's child.
+			 */
+			Assert(IsA(innerplan, Hash));
+			Assert(elidedinner == NULL);
+			elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			strategy = JSTRAT_HASH_JOIN;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+	}
+
+	/*
+	 * The planner may have decided to implement a semijoin by first making
+	 * the nullable side of the plan unique, and then performing a normal join
+	 * against the result. Therefore, we might need to descend through a
+	 * unique node on either side of the plan.
+	 */
+	uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+	uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+	/*
+	 * The planner may have decided to parallelize part of the join tree, so
+	 * we could find a Gather or Gather Merge node here. Note that, if
+	 * present, this will appear below nodes we considered as part of the join
+	 * strategy, but we could find another uniqueness-enforcing node below the
+	 * Gather or Gather Merge, if present.
+	 */
+	if (elidedouter == NULL)
+	{
+		elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+											  found_any_outer_gather);
+		if (found_any_outer_gather &&
+			pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+			uniqueouter = true;
+	}
+	if (elidedinner == NULL)
+	{
+		elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+											  found_any_inner_gather);
+		if (found_any_inner_gather &&
+			pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+			uniqueinner = true;
+	}
+
+	/*
+	 * It's possible that Result node has been inserted either to project a
+	 * target list or to implement a one-time filter. If so, we can descend
+	 * throught it. Note that a result node without a child would be a
+	 * degenerate scan or join, and not something we could descend through.
+	 *
+	 * XXX. I suspect it's possible for this to happen above the Gather or
+	 * Gather Merge node, too, but apparently we have no test case for that
+	 * scenario.
+	 */
+	if (elidedouter == NULL && is_result_node_with_child(outerplan))
+		elidedouter = pgpa_descend_node(pstmt, &outerplan);
+	if (elidedinner == NULL && is_result_node_with_child(innerplan))
+		elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+	/*
+	 * If this is a semijoin that was converted to an inner join by making one
+	 * side or the other unique, make a note that the inner or outer subplan,
+	 * as appropriate, should be treated as a query plan feature when the main
+	 * tree traversal reaches it.
+	 *
+	 * Conversely, if the planner could have made one side of the join unique
+	 * and thereby converted it to an inner join, and chose not to do so, that
+	 * is also worth noting.
+	 *
+	 * XXX: We admit too much non-unique advice, as in the following example
+	 * from the regression tests: EXPLAIN (PLAN_ADVICE, COSTS OFF) DELETE FROM
+	 * prt1_l WHERE EXISTS (SELECT 1 FROM int4_tbl, LATERAL (SELECT
+	 * int4_tbl.f1 FROM int8_tbl LIMIT 2) ss WHERE prt1_l.c IS NULL). We emit
+	 * SEMIJOIN_NON_UNIQUE((int4_tbl ss)) but create_unique_path() fails in
+	 * this case, so there's no sj-unique version possible.
+	 *
+	 * NB: This code could appear slightly higher up in in this function, but
+	 * none of the nodes through which we just descended should be have
+	 * associated RTIs.
+	 *
+	 * NB: This seems like a somewhat hacky way of passing information up to
+	 * the main tree walk, but I don't currently have a better idea.
+	 */
+	if (uniqueouter)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+	else if (jointype == JOIN_RIGHT_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+	if (uniqueinner)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+	else if (jointype == JOIN_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+	/* Set output parameters. */
+	*realouter = outerplan;
+	*realinner = innerplan;
+	*elidedrealouter = elidedouter;
+	*elidedrealinner = elidedinner;
+	return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+	*plan = (*plan)->lefttree;
+	return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+						bool *found_any_gather)
+{
+	if (IsA(*plan, Gather))
+	{
+		*found_any_gather = true;
+		return pgpa_descend_node(pstmt, plan);
+	}
+
+	if (IsA(*plan, GatherMerge))
+	{
+		ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+		if (elided == NULL && is_sorting_plan(*plan))
+			elided = pgpa_descend_node(pstmt, plan);
+
+		*found_any_gather = true;
+		return elided;
+	}
+
+	return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through at least one node, and
+ * otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+						ElidedNode **elided_node)
+{
+	if (*elided_node != NULL)
+		return false;
+
+	if (IsA(*plan, Agg) || IsA(*plan, Unique))
+	{
+		*elided_node = pgpa_descend_node(pstmt, plan);
+
+		if (*elided_node == NULL && is_sorting_plan(*plan))
+			*elided_node = pgpa_descend_node(pstmt, plan);
+
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+	return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+	return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
diff --git a/contrib/pg_plan_advice/pgpa_join.h b/contrib/pg_plan_advice/pgpa_join.h
new file mode 100644
index 00000000000..4dc72986a70
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.h
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+	JSTRAT_MERGE_JOIN_PLAIN = 0,
+	JSTRAT_MERGE_JOIN_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_PLAIN,
+	JSTRAT_NESTED_LOOP_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_MEMOIZE,
+	JSTRAT_HASH_JOIN
+	/* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY		((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+	Plan	   *plan;
+	ElidedNode *elided_node;
+	struct pgpa_scan *scan;
+	pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>.  When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+	/* Outermost member; must not itself be an unrolled join. */
+	pgpa_join_member outer;
+
+	/* Number of inner members. Length of the strategy and inner arrays. */
+	unsigned	ninner;
+
+	/* Array of strategies, one per non-outermost member. */
+	pgpa_join_strategy *strategy;
+
+	/* Array of members, excluding the outermost. Deepest first. */
+	pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+	return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+							 Plan *plan, bool beneath_any_gather,
+							 pgpa_join_unroller *join_unroller,
+							 pgpa_join_unroller **outer_join_unroller,
+							 pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+													pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
new file mode 100644
index 00000000000..2175278b580
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -0,0 +1,624 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+	const char **rid_strings;
+	StringInfo	buf;
+	int			wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+									  pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+									pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+									  pgpa_scan_strategy strategy,
+									  List *scans);
+static void pgpa_output_bitmap_index_details(pgpa_output_context *context,
+											 Plan *plan);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+									  pgpa_qf_type type,
+									  List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+										char *strategy,
+										List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+								  Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+								  Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+				   pgpa_identifier *rt_identifiers)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	ListCell   *lc;
+	pgpa_output_context context;
+
+	/* Basic initialization. */
+	memset(&context, 0, sizeof(pgpa_output_context));
+	context.buf = buf;
+
+	/*
+	 * Convert identifiers to string form. Note that the loop variable here is
+	 * not an RTI, because RTIs are 1-based. Some RTIs will have no
+	 * identifier, either because the reloptkind is RTE_JOIN or because that
+	 * portion of the query didn't make it into the final plan.
+	 */
+	context.rid_strings = palloc0_array(const char *, rtable_length);
+	for (int i = 0; i < rtable_length; ++i)
+		if (rt_identifiers[i].alias_name != NULL)
+			context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+	/*
+	 * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+	 * from a psql client with default settings, psql will add one space to
+	 * the left of the output and EXPLAIN will add two more to the left of the
+	 * advice. Thus, lines of more than 77 characters will wrap. We set the
+	 * wrap limit to 76 here so that the output won't reach all the way to the
+	 * very last column of the terminal.
+	 *
+	 * Of course, this is fairly arbitrary set of assumptions, and one could
+	 * well make an argument for a different wrap limit, or for a configurable
+	 * one.
+	 */
+	context.wrap_column = 76;
+
+	/*
+	 * Each piece of JOIN_ORDER() advice fully describes the join order for a
+	 * a single unrolled join. Merging is not permitted, because that would
+	 * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+	 * scans should be used for all of those relations, and is thus equivalent
+	 * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+	 * is the driving table which is then joined to "b" then "c" then "d",
+	 * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+	 */
+	foreach(lc, walker->toplevel_unrolled_joins)
+	{
+		pgpa_unrolled_join *ujoin = lfirst(lc);
+
+		if (buf->len > 0)
+			appendStringInfoChar(buf, '\n');
+		appendStringInfo(context.buf, "JOIN_ORDER(");
+		pgpa_output_unrolled_join(&context, ujoin);
+		appendStringInfoChar(context.buf, ')');
+		pgpa_maybe_linebreak(context.buf, context.wrap_column);
+	}
+
+	/* Emit join strategy advice. */
+	for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+	{
+		char	   *strategy = pgpa_cstring_join_strategy(s);
+
+		pgpa_output_simple_strategy(&context,
+									strategy,
+									walker->join_strategies[s]);
+	}
+
+	/*
+	 * Emit scan strategy advice (but not for ordinary scans, which are
+	 * definitionally uninteresting).
+	 */
+	for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+		if (c != PGPA_SCAN_ORDINARY)
+			pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+	/* Emit query feature advice. */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+		pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+	/* Emit NO_GATHER advice. */
+	pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+						  pgpa_unrolled_join *join)
+{
+	pgpa_output_join_member(context, &join->outer);
+
+	for (int k = 0; k < join->ninner; ++k)
+	{
+		pgpa_join_member *member = &join->inner[k];
+
+		pgpa_maybe_linebreak(context->buf, context->wrap_column);
+		appendStringInfoChar(context->buf, ' ');
+		pgpa_output_join_member(context, member);
+	}
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+						pgpa_join_member *member)
+{
+	if (member->unrolled_join != NULL)
+	{
+		appendStringInfoChar(context->buf, '(');
+		pgpa_output_unrolled_join(context, member->unrolled_join);
+		appendStringInfoChar(context->buf, ')');
+	}
+	else
+	{
+		pgpa_scan  *scan = member->scan;
+
+		Assert(scan != NULL);
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '{');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, '}');
+		}
+	}
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+						  pgpa_scan_strategy strategy,
+						  List *scans)
+{
+	bool		first = true;
+
+	if (scans == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_scan_strategy(strategy));
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		Plan	   *plan = scan->plan;
+
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		/* Output the relation identifiers. */
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+
+		/* For scans involving indexes, output index information. */
+		if (strategy == PGPA_SCAN_INDEX)
+		{
+			Assert(IsA(plan, IndexScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_INDEX_ONLY)
+		{
+			Assert(IsA(plan, IndexOnlyScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context,
+									  ((IndexOnlyScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_BITMAP_HEAP)
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_bitmap_index_details(context, plan->lefttree);
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output information about which index or indexes power a BitmapHeapScan.
+ *
+ * We emit &&(i1 i2 i3) for a BitmapAnd between indexes i1, i2, and i3;
+ * and likewise ||(i1 i2 i3) for a similar BitmapOr operation.
+ */
+static void
+pgpa_output_bitmap_index_details(pgpa_output_context *context, Plan *plan)
+{
+	char	   *operator;
+	List	   *bitmapplans;
+	bool		first = true;
+
+	if (IsA(plan, BitmapIndexScan))
+	{
+		BitmapIndexScan *bitmapindexscan = (BitmapIndexScan *) plan;
+
+		pgpa_output_relation_name(context, bitmapindexscan->indexid);
+		return;
+	}
+
+	if (IsA(plan, BitmapOr))
+	{
+		operator = "||";
+		bitmapplans = ((BitmapOr *) plan)->bitmapplans;
+	}
+	else if (IsA(plan, BitmapAnd))
+	{
+		operator = "&&";
+		bitmapplans = ((BitmapAnd *) plan)->bitmapplans;
+	}
+	else
+		elog(ERROR, "unexpected node type: %d", (int) nodeTag(plan));
+
+	appendStringInfo(context->buf, "%s(", operator);
+	foreach_ptr(Plan, child_plan, bitmapplans)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+		pgpa_output_bitmap_index_details(context, child_plan);
+	}
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+	Oid			nspoid = get_rel_namespace(relid);
+	char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+	char	   *relname = get_rel_name(relid);
+
+	appendStringInfoString(context->buf, quote_identifier(relnamespace));
+	appendStringInfoChar(context->buf, '.');
+	appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+						  List *query_features)
+{
+	bool		first = true;
+
+	if (query_features == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_query_feature_type(type));
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(qf->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, qf->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, qf->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+							List *relid_sets)
+{
+	bool		first = true;
+
+	if (relid_sets == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(", strategy);
+
+	foreach_node(Bitmapset, relids, relid_sets)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+	if (relids == NULL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "NO_GATHER(");
+	pgpa_output_relations(context, context->buf, relids);
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+					  Bitmapset *relids)
+{
+	int			rti = -1;
+	bool		first = true;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		const char *rid_string = context->rid_strings[rti - 1];
+
+		if (rid_string == NULL)
+			elog(ERROR, "no identifier for RTI %d", rti);
+
+		if (first)
+		{
+			first = false;
+			appendStringInfoString(buf, rid_string);
+		}
+		else
+		{
+			pgpa_maybe_linebreak(buf, context->wrap_column);
+			appendStringInfo(buf, " %s", rid_string);
+		}
+	}
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+	switch (strategy)
+	{
+		case JSTRAT_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case JSTRAT_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case JSTRAT_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case JSTRAT_HASH_JOIN:
+			return "HASH_JOIN";
+	}
+
+	Assert(false);
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+	switch (strategy)
+	{
+		case PGPA_SCAN_ORDINARY:
+			return "ORDINARY_SCAN";
+		case PGPA_SCAN_SEQ:
+			return "SEQ_SCAN";
+		case PGPA_SCAN_BITMAP_HEAP:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_SCAN_FOREIGN:
+			return "FOREIGN_JOIN";
+		case PGPA_SCAN_INDEX:
+			return "INDEX_SCAN";
+		case PGPA_SCAN_INDEX_ONLY:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_SCAN_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_SCAN_TID:
+			return "TID_SCAN";
+	}
+
+	Assert(false);
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+	switch (type)
+	{
+		case PGPAQF_GATHER:
+			return "GATHER";
+		case PGPAQF_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPAQF_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPAQF_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+	}
+
+	Assert(false);
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+	char	   *trailing_nl;
+	int			line_start;
+	int			save_cursor;
+
+	/* If line wrapping is disabled, exit quickly. */
+	if (wrap_column <= 0)
+		return;
+
+	/*
+	 * Set line_start to the byte offset within buf->data of the first
+	 * character of the current line, where the current line means the last
+	 * one in the buffer. Note that line_start could be the offset of the
+	 * trailing '\0' if the last character in the buffer is a line break.
+	 */
+	trailing_nl = strrchr(buf->data, '\n');
+	if (trailing_nl == NULL)
+		line_start = 0;
+	else
+		line_start = (trailing_nl - buf->data) + 1;
+
+	/*
+	 * Remember that the current end of the buffer is a potential location to
+	 * insert a line break on a future call to this function.
+	 */
+	save_cursor = buf->cursor;
+	buf->cursor = buf->len;
+
+	/* If we haven't passed the wrap column, we don't need a newline. */
+	if (buf->len - line_start <= wrap_column)
+		return;
+
+	/*
+	 * It only makes sense to insert a newline at a position later than the
+	 * beginning of the current line.
+	 */
+	if (buf->cursor <= line_start)
+		return;
+
+	/* Insert a newline at the previous cursor location. */
+	enlargeStringInfo(buf, 1);
+	memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+			buf->len - save_cursor);
+	++buf->cursor;
+	buf->data[++buf->len] = '\0';
+	buf->data[save_cursor] = '\n';
+}
diff --git a/contrib/pg_plan_advice/pgpa_output.h b/contrib/pg_plan_advice/pgpa_output.h
new file mode 100644
index 00000000000..47496d76f52
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+							   pgpa_plan_walker_context *walker,
+							   pgpa_identifier *rt_identifiers);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_parser.y b/contrib/pg_plan_advice/pgpa_parser.y
new file mode 100644
index 00000000000..4617e7f2f64
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_parser.y
@@ -0,0 +1,337 @@
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc.  This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE   pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+	char	   *str;
+	int			integer;
+	List	   *list;
+	pgpa_advice_item *item;
+	pgpa_advice_target *target;
+	pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_BITMAP TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+%token TOK_OR TOK_AND
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list bitmap_sublist bitmap_target_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> bitmap_target_item index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+		{
+			(void) yynerrs;				/* suppress compiler warning */
+			*result = $1;
+		}
+	;
+
+advice_item_list: advice_item_list advice_item
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_JOIN_ORDER;
+			$$->targets = $3;
+		}
+	| TOK_TAG_INDEX '(' index_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "index_only_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+			else if (strcmp($1, "index_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_BITMAP '(' bitmap_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+			$$->targets = $3;
+		}
+	| TOK_TAG_SIMPLE '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "no_gather") == 0)
+				$$->tag = PGPA_TAG_NO_GATHER;
+			else if (strcmp($1, "seq_scan") == 0)
+				$$->tag = PGPA_TAG_SEQ_SCAN;
+			else if (strcmp($1, "tid_scan") == 0)
+				$$->tag = PGPA_TAG_TID_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_GENERIC '(' generic_target_list ')'
+		{
+			bool	fail;
+
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = pgpa_parse_advice_tag($1, &fail);
+			if (fail)
+			{
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "unrecognized advice tag");
+			}
+
+			if ($$->tag == PGPA_TAG_FOREIGN_JOIN)
+			{
+				foreach_ptr(pgpa_advice_target, target, $3)
+				{
+					if (target->ttype == PGPA_TARGET_IDENTIFIER ||
+						list_length(target->children) == 1)
+							pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+										 "FOREIGN_JOIN targets must contain more than one relation identifier");
+				}
+			}
+
+			$$->targets = $3;
+		}
+	;
+
+relation_identifier: identifier opt_ri_occurrence opt_partition opt_plan_name
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_IDENTIFIER;
+			$$->rid.alias_name = $1;
+			$$->rid.occurrence = $2;
+			if (list_length($3) == 2)
+			{
+				$$->rid.partnsp = linitial($3);
+				$$->rid.partrel = lsecond($3);
+			}
+			else if ($3 != NIL)
+				$$->rid.partrel = linitial($3);
+			$$->rid.plan_name = $4;
+		}
+	;
+
+index_name: identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indname = $1;
+		}
+	| identifier '.' identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indnamespace = $1;
+			$$->indname = $3;
+		}
+	;
+
+opt_ri_occurrence:
+	'#' TOK_INTEGER
+		{
+			if ($2 <= 0)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "only positive occurrence numbers are permitted");
+			$$ = $2;
+		}
+	|
+		{
+			/* The default occurrence number is 1. */
+			$$ = 1;
+		}
+	;
+
+identifier: TOK_IDENT
+	| TOK_TAG_JOIN_ORDER
+	| TOK_TAG_INDEX
+	| TOK_TAG_BITMAP
+	| TOK_TAG_SIMPLE
+	| TOK_TAG_GENERIC
+	;
+
+/*
+ * When generating advice, we always schema-qualify the partition name, but
+ * when parsing advice, we accept a specification that lacks one.
+ */
+opt_partition:
+	'/' TOK_IDENT '.' TOK_IDENT
+		{ $$ = list_make2($2, $4); }
+	| '/' TOK_IDENT
+		{ $$ = list_make1($2); }
+	|
+		{ $$ = NIL; }
+	;
+
+opt_plan_name:
+	'@' TOK_IDENT
+		{ $$ = $2; }
+	|
+		{ $$ = NULL; }
+	;
+
+bitmap_target_list: bitmap_target_list relation_identifier bitmap_target_item
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+bitmap_target_item: index_name
+		{ $$ = $1; }
+	| TOK_OR '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_OR;
+			$$->children = $3;
+		}
+	| TOK_AND '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_AND;
+			$$->children = $3;
+		}
+	;
+
+bitmap_sublist: bitmap_sublist bitmap_target_item
+		{ $$ = lappend($1, $2); }
+	| bitmap_target_item
+		{ $$ = list_make1($1); }
+	;
+
+generic_target_list: generic_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| generic_target_list generic_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+generic_sublist: '(' generic_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+index_target_list:
+	  index_target_list relation_identifier index_name
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_target_list: join_order_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| join_order_target_list join_order_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_sublist:
+	'(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	| '{' simple_target_list '}'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_UNORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+simple_target_list: simple_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+%%
+
+/*
+ * Parse an advice_string and return the resulting list of pgpa_advice_item
+ * objects. If a parse error occurs, instead return NULL.
+ *
+ * If the return value is NULL, *error_p will be set to the error message;
+ * otherwise, *error_p will be set to NULL.
+ */
+List *
+pgpa_parse(const char *advice_string, char **error_p)
+{
+	yyscan_t	scanner;
+	List	   *result;
+	char	   *error = NULL;
+
+	pgpa_scanner_init(advice_string, &scanner);
+	pgpa_yyparse(&result, &error, scanner);
+	pgpa_scanner_finish(scanner);
+
+	if (error != NULL)
+	{
+		*error_p = error;
+		return NULL;
+	}
+
+	*error_p = NULL;
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
new file mode 100644
index 00000000000..c4859c23020
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -0,0 +1,1706 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.c
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pga_planner.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "common/hashfn_unstable.h"
+#include "nodes/makefuncs.h"
+#include "optimizer/extendplan.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/plancat.h"
+#include "optimizer/planner.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * When assertions are enabled, we try generating relation identifiers during
+ * planning, saving them in a hash table, and then cross-checking them against
+ * the ones generated after planning is complete.
+ */
+typedef struct pgpa_ri_checker_key
+{
+	char	   *plan_name;
+	Index		rti;
+} pgpa_ri_checker_key;
+
+typedef struct pgpa_ri_checker
+{
+	pgpa_ri_checker_key key;
+	uint32		status;
+	const char *rid_string;
+} pgpa_ri_checker;
+
+static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
+
+static inline bool
+pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
+{
+	if (a.rti != b.rti)
+		return false;
+	if (a.plan_name == NULL)
+		return (b.plan_name == NULL);
+	if (b.plan_name == NULL)
+		return false;
+	return strcmp(a.plan_name, b.plan_name) == 0;
+}
+
+#define SH_PREFIX			pgpa_ri_check
+#define SH_ELEMENT_TYPE		pgpa_ri_checker
+#define SH_KEY_TYPE			pgpa_ri_checker_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#endif
+
+typedef struct pgpa_planner_state
+{
+	ExplainState *explain_state;
+	pgpa_trove *trove;
+	MemoryContext trove_cxt;
+
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_check_hash *ri_check_hash;
+#endif
+} pgpa_planner_state;
+
+typedef struct pgpa_join_state
+{
+	/* Most-recently-considered outer rel. */
+	RelOptInfo *outerrel;
+
+	/* Most-recently-considered inner rel. */
+	RelOptInfo *innerrel;
+
+	/*
+	 * Array of relation identifiers for all members of this joinrel, with
+	 * outerrel idenifiers before innerrel identifiers.
+	 */
+	pgpa_identifier *rids;
+
+	/* Number of outer rel identifiers. */
+	int			outer_count;
+
+	/* Number of inner rel identifiers. */
+	int			inner_count;
+
+	/*
+	 * Trove lookup results.
+	 *
+	 * join_entries and rel_entries are arrays of entries, and join_indexes
+	 * and rel_indexes are the integer offsets within those arrays of entries
+	 * potentially relevant to us. The "join" fields correspond to a lookup
+	 * using PGPA_TROVE_LOOKUP_JOIN and the "rel" fields to a lookup using
+	 * PGPA_TROVE_LOOKUP_REL.
+	 */
+	pgpa_trove_entry *join_entries;
+	Bitmapset  *join_indexes;
+	pgpa_trove_entry *rel_entries;
+	Bitmapset  *rel_indexes;
+} pgpa_join_state;
+
+/* Saved hook values */
+static get_relation_info_hook_type prev_get_relation_info = NULL;
+static join_path_setup_hook_type prev_join_path_setup = NULL;
+static joinrel_setup_hook_type prev_joinrel_setup = NULL;
+static planner_setup_hook_type prev_planner_setup = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+
+/* Other global variabes */
+static int	planner_extension_id = -1;
+
+/* Function prototypes. */
+static void pgpa_get_relation_info(PlannerInfo *root,
+								   Oid relationObjectId,
+								   bool inhparent,
+								   RelOptInfo *rel);
+static void pgpa_joinrel_setup(PlannerInfo *root,
+							   RelOptInfo *joinrel,
+							   RelOptInfo *outerrel,
+							   RelOptInfo *innerrel,
+							   SpecialJoinInfo *sjinfo,
+							   List *restrictlist);
+static void pgpa_join_path_setup(PlannerInfo *root,
+								 RelOptInfo *joinrel,
+								 RelOptInfo *outerrel,
+								 RelOptInfo *innerrel,
+								 JoinType jointype,
+								 JoinPathExtraData *extra);
+static void pgpa_planner_setup(PlannerGlobal *glob, Query *parse,
+							   const char *query_string,
+							   double *tuple_fraction,
+							   ExplainState *es);
+static void pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string, PlannedStmt *pstmt);
+static void pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p,
+											  char *plan_name,
+											  pgpa_join_state *pjs);
+static void pgpa_planner_apply_join_path_advice(JoinType jointype,
+												uint64 *pgs_mask_p,
+												char *plan_name,
+												pgpa_join_state *pjs);
+static void pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+										   pgpa_trove_entry *scan_entries,
+										   Bitmapset *scan_indexes,
+										   pgpa_trove_entry *rel_entries,
+										   Bitmapset *rel_indexes);
+static uint64 pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag);
+static bool pgpa_join_order_permits_join(int outer_count, int inner_count,
+										 pgpa_identifier *rids,
+										 pgpa_trove_entry *entry);
+static bool pgpa_join_method_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+
+static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+										  pgpa_trove_lookup_type type,
+										  pgpa_identifier *rt_identifiers,
+										  pgpa_plan_walker_context *walker);
+
+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);
+
+/*
+ * Install planner-related hooks.
+ */
+void
+pgpa_planner_install_hooks(void)
+{
+	planner_extension_id = GetPlannerExtensionId("pg_plan_advice");
+	prev_get_relation_info = get_relation_info_hook;
+	get_relation_info_hook = pgpa_get_relation_info;
+	prev_joinrel_setup = joinrel_setup_hook;
+	joinrel_setup_hook = pgpa_joinrel_setup;
+	prev_join_path_setup = join_path_setup_hook;
+	join_path_setup_hook = pgpa_join_path_setup;
+	prev_planner_setup = planner_setup_hook;
+	planner_setup_hook = pgpa_planner_setup;
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgpa_planner_shutdown;
+}
+
+/*
+ * Hook function for get_relation_info().
+ *
+ * We can apply scan advice at this opint, and we also usee this as an
+ * opportunity to do range-table identifier cross-checking in assert-enabled
+ * builds.
+ *
+ * XXX: We currently emit useless advice like NO_GATHER("*RESULT*") for trivial
+ * queries. The advice is useless because get_relation_info isn't called for
+ * non-relation RTEs. We should either suppress the advice in such cases, or
+ * add a hook that can apply it.
+ */
+static void
+pgpa_get_relation_info(PlannerInfo *root, Oid relationObjectId,
+					   bool inhparent, RelOptInfo *rel)
+{
+	pgpa_planner_state *pps;
+
+	/* 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. */
+	if (pps != NULL)
+		pgpa_ri_checker_save(pps, root, rel);
+
+	/* If query advice was provided, search for relevant entries. */
+	if (pps != NULL && pps->trove != NULL)
+	{
+		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,
+						  &tresult_scan);
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+						  &tresult_rel);
+
+		/* If relevant entries were found, apply them. */
+		if (tresult_scan.indexes != NULL || tresult_rel.indexes != NULL)
+			pgpa_planner_apply_scan_advice(rel,
+										   tresult_scan.entries,
+										   tresult_scan.indexes,
+										   tresult_rel.entries,
+										   tresult_rel.indexes);
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_get_relation_info)
+		(*prev_get_relation_info) (root, relationObjectId, inhparent, rel);
+}
+
+/*
+ * Search for advice pertaining to a proposed join.
+ */
+static pgpa_join_state *
+pgpa_get_join_state(PlannerInfo *root, RelOptInfo *joinrel,
+					RelOptInfo *outerrel, RelOptInfo *innerrel)
+{
+	pgpa_planner_state *pps;
+	pgpa_join_state *pjs;
+	bool		new_pjs = false;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+	if (pps == NULL || pps->trove == NULL)
+	{
+		/* No advice applies to this query, hence none to this joinrel. */
+		return NULL;
+	}
+
+	/*
+	 * See whether we've previously associated a pgpa_join_state with this
+	 * joinrel. If we have not, we need to try to construct one. If we have,
+	 * then there are two cases: (a) if innerrel and outerrel are unchanged,
+	 * we can simply use it, and (b) if they have changed, we need to rejigger
+	 * the array of identifiers but can still skip the trove lookup.
+	 */
+	pjs = GetRelOptInfoExtensionState(joinrel, planner_extension_id);
+	if (pjs != NULL)
+	{
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+		{
+			/*
+			 * If there's no potentially relevant advice, then the presence of
+			 * this pgpa_join_state acts like a negative cache entry: it tells
+			 * us not to bother searching the trove for advice, because we
+			 * will not find any.
+			 */
+			return NULL;
+		}
+
+		if (pjs->outerrel == outerrel && pjs->innerrel == innerrel)
+		{
+			/* No updates required, so just return. */
+			/* XXX. Does this need to do something different under GEQO? */
+			return pjs;
+		}
+	}
+
+	/*
+	 * If there's no pgpa_join_state yet, we need to allocate one. Trove keys
+	 * will not get built for RTE_JOIN RTEs, so the array may end up being
+	 * larger than needed. It's not worth trying to compute a perfectly
+	 * accurate count here.
+	 */
+	if (pjs == NULL)
+	{
+		int			pessimistic_count = bms_num_members(joinrel->relids);
+
+		pjs = palloc0_object(pgpa_join_state);
+		pjs->rids = palloc_array(pgpa_identifier, pessimistic_count);
+		new_pjs = true;
+	}
+
+	/*
+	 * Either we just allocated a new pgpa_join_state, or the existing one
+	 * needs reconfiguring for a new innerrel and outerrel. The required array
+	 * size can't change, so we can overwrite the existing one.
+	 */
+	pjs->outerrel = outerrel;
+	pjs->innerrel = innerrel;
+	pjs->outer_count =
+		pgpa_compute_identifiers_by_relids(root, outerrel->relids, pjs->rids);
+	pjs->inner_count =
+		pgpa_compute_identifiers_by_relids(root, innerrel->relids,
+										   pjs->rids + pjs->outer_count);
+
+	/*
+	 * If we allocated a new pgpa_join_state, search our trove of advice for
+	 * relevant entries. The trove lookup will return the same results for
+	 * every outerrel/innerrel combination, so we don't need to repeat that
+	 * work every time.
+	 */
+	if (new_pjs)
+	{
+		pgpa_trove_result tresult;
+
+		/* Find join entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_JOIN,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->join_entries = tresult.entries;
+		pjs->join_indexes = tresult.indexes;
+
+		/* Find rel entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->rel_entries = tresult.entries;
+		pjs->rel_indexes = tresult.indexes;
+
+		/* Now that the new pgpa_join_state is fully valid, save a pointer. */
+		SetRelOptInfoExtensionState(joinrel, planner_extension_id, pjs);
+
+		/*
+		 * If there was no relevant advice found, just return NULL. This
+		 * pgpa_join_state will stick around as a sort of negative cache
+		 * entry, so that future calls for this same joinrel quickly return
+		 * NULL.
+		 */
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+			return NULL;
+	}
+
+	return pjs;
+}
+
+/*
+ * Enforce any provided advice that is relevant to any method of implementing
+ * this join.
+ *
+ * Although we're passed the outerrel and innerrel here, those are just
+ * whatever values happened to prompt the creation of this joinrel; they
+ * shouldn't really influence our choice of what advice to apply.
+ */
+static void
+pgpa_joinrel_setup(PlannerInfo *root, RelOptInfo *joinrel,
+				   RelOptInfo *outerrel, RelOptInfo *innerrel,
+				   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_joinrel_advice(&joinrel->pgs_mask,
+										  root->plan_name,
+										  pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_joinrel_setup)
+		(*prev_joinrel_setup) (root, joinrel, outerrel, innerrel,
+							   sjinfo, restrictlist);
+}
+
+/*
+ * Enforce any provided advice that is relevant to this particular method of
+ * implementing this particular join.
+ */
+static void
+pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
+					 RelOptInfo *outerrel, RelOptInfo *innerrel,
+					 JoinType jointype, JoinPathExtraData *extra)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_join_path_advice(jointype,
+											&extra->pgs_mask,
+											root->plan_name,
+											pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_join_path_setup)
+		(*prev_join_path_setup) (root, joinrel, outerrel, innerrel,
+								 jointype, extra);
+}
+
+/*
+ * Prepare advice for use by a query.
+ */
+static void
+pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
+				   double *tuple_fraction, ExplainState *es)
+{
+	pgpa_trove *trove = NULL;
+	pgpa_planner_state *pps;
+	char	   *error;
+	bool		needs_pps = false;
+
+	/*
+	 * If any advice was provided, build a trove of advice for use during
+	 * planning.
+	 */
+	if (pg_plan_advice_advice != NULL && pg_plan_advice_advice[0] != '\0')
+	{
+		List	   *advice_items;
+
+		/*
+		 * Parsing shouldn't fail here, because we must have previously parsed
+		 * successfully in pg_plan_advice_advice_check_hook, but if it does,
+		 * emit a warning.
+		 */
+		advice_items = pgpa_parse(pg_plan_advice_advice, &error);
+		if (error)
+			elog(WARNING, "could not parse advice: %s", error);
+
+		/*
+		 * It's possible that the advice string was non-empty but contained no
+		 * actual advice, e.g. it was all whitespace.
+		 */
+		if (advice_items != NIL)
+		{
+			trove = pgpa_build_trove(advice_items);
+			needs_pps = true;
+		}
+	}
+
+#ifdef USE_ASSERT_CHECKING
+
+	/*
+	 * If asserts are enabled, always build a private state object for
+	 * cross-checks.
+	 */
+	needs_pps = true;
+#endif
+
+	/* Initialize and store private state, if required. */
+	if (needs_pps)
+	{
+		pps = palloc0_object(pgpa_planner_state);
+		pps->explain_state = es;
+		pps->trove = trove;
+#ifdef USE_ASSERT_CHECKING
+		pps->ri_check_hash =
+			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
+#endif
+		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
+	}
+}
+
+/*
+ * Carry out whatever work we want to do after planning is complete.
+ */
+static void
+pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	pgpa_planner_state *pps;
+	pgpa_trove *trove = NULL;
+	ExplainState *es = NULL;
+	pgpa_plan_walker_context walker = {0};	/* placate compiler */
+	bool		do_advice_feedback;
+	bool		do_collect_advice;
+	List	   *pgpa_items = NIL;
+	pgpa_identifier *rt_identifiers = NULL;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
+	if (pps != NULL)
+	{
+		trove = pps->trove;
+		es = pps->explain_state;
+	}
+
+	/* If at least one collector is enabled, generate advice. */
+	do_collect_advice = (pg_plan_advice_local_collection_limit > 0 ||
+						 pg_plan_advice_shared_collection_limit > 0);
+
+	/* If we applied advice, generate feedback. */
+	do_advice_feedback = (trove != NULL && es != NULL);
+
+	/* If either of the above apply, analyze the resulting PlannedStmt. */
+	if (do_collect_advice || do_advice_feedback)
+	{
+		pgpa_plan_walker(&walker, pstmt);
+		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+	}
+
+	/*
+	 * If advice collection is enabled, put the advice in string form and send
+	 * it to the collector.
+	 */
+	if (do_collect_advice)
+	{
+		char	   *advice_string;
+		StringInfoData buf;
+
+		/* Generate a textual advice string. */
+		initStringInfo(&buf);
+		pgpa_output_advice(&buf, &walker, rt_identifiers);
+		advice_string = buf.data;
+
+		/* If the advice string is empty, don't bother collecting it. */
+		if (advice_string[0] != '\0')
+			pgpa_collect_advice(pstmt->queryId, query_string, advice_string);
+
+		/*
+		 * If we've gone to the trouble of generating an advice string, and if
+		 * we're inside EXPLAIN, save the string so we don't need to
+		 * regenerate it.
+		 */
+		if (es != NULL)
+			pgpa_items = lappend(pgpa_items,
+								 makeDefElem("advice_string",
+											 (Node *) makeString(advice_string),
+											 -1));
+	}
+
+	/*
+	 * If we are planning within EXPLAIN, make arrangements to allow EXPLAIN
+	 * to tell the user what has happened with the provided advice.
+	 *
+	 * NB: If EXPLAIN is used on a prepared is a prepared statement, planning
+	 * will have already happened happened without recording these details. We
+	 * could consider adding a GUC to cater to that scenario; or we could do
+	 * this work all the time, but that seems like too much overhead.
+	 */
+	if (do_advice_feedback)
+	{
+		List	   *feedback = NIL;
+
+		/*
+		 * Inject a Node-tree representation of all the trove-entry flags into
+		 * the PlannedStmt.
+		 */
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_SCAN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_JOIN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_REL,
+												rt_identifiers, &walker);
+
+		pgpa_items = lappend(pgpa_items, makeDefElem("feedback",
+													 (Node *) feedback,
+													 -1));
+	}
+
+	/* Push whatever data we're saving into the PlannedStmt. */
+	if (pgpa_items != NIL)
+		pstmt->extension_state =
+			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);
+}
+
+/*
+ * Enforce overall restrictions on a join relation that apply uniformly
+ * regardless of the choice of inner and outer rel.
+ */
+static void
+pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
+								  pgpa_join_state *pjs)
+{
+	int			i = -1;
+	int			flags;
+	bool		gather_conflict = false;
+	uint64		gather_mask = 0;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	bool		partitionwise_conflict = false;
+	int			partitionwise_outcome = 0;
+	Bitmapset  *partitionwise_partial_match = NULL;
+	Bitmapset  *partitionwise_full_match = NULL;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type itm;
+		bool		full_match = false;
+		uint64		my_gather_mask = 0;
+		int			my_partitionwise_outcome = 0;	/* >0 yes, <0 no */
+
+		/*
+		 * For GATHER and GATHER_MERGE, if the specified relations exactly
+		 * match this joinrel, do whatever the advice says; otherwise, don't
+		 * allow Gather or Gather Merge at this level. For NO_GATHER, there
+		 * must be a single target relation which must be included in this
+		 * joinrel, so just don't allow Gather or Gather Merge here, full
+		 * stop.
+		 */
+		if (entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			full_match = true;
+		}
+		else
+		{
+			int			total_count;
+
+			total_count = pjs->outer_count + pjs->inner_count;
+			itm = pgpa_identifiers_match_target(total_count, pjs->rids,
+												entry->target);
+			Assert(itm != PGPA_ITM_DISJOINT);
+
+			if (itm == PGPA_ITM_EQUAL)
+			{
+				full_match = true;
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+					my_partitionwise_outcome = 1;
+				else if (entry->tag == PGPA_TAG_GATHER)
+					my_gather_mask = PGS_GATHER;
+				else if (entry->tag == PGPA_TAG_GATHER_MERGE)
+					my_gather_mask = PGS_GATHER_MERGE;
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+			else
+			{
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else if (entry->tag == PGPA_TAG_GATHER ||
+						 entry->tag == PGPA_TAG_GATHER_MERGE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (full_match)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+
+		/*
+		 * Likewise, if we set my_partitionwise_outcome up above, then we (1)
+		 * make a note if the advice conflicted, (2) remember what the desired
+		 * outcome was, and (3) remember whether this was a full or partial
+		 * match.
+		 */
+		if (my_partitionwise_outcome != 0)
+		{
+			if (partitionwise_outcome != 0 &&
+				partitionwise_outcome != my_partitionwise_outcome)
+				partitionwise_conflict = true;
+			partitionwise_outcome = my_partitionwise_outcome;
+			if (full_match)
+				partitionwise_full_match =
+					bms_add_member(partitionwise_full_match, i);
+			else
+				partitionwise_partial_match =
+					bms_add_member(partitionwise_partial_match, i);
+		}
+	}
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched, and if
+	 * the set of targets exactly matched this relation, fully matched. If
+	 * there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
+
+	/* Likewise for partitionwise advice. */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (partitionwise_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		*pgs_mask_p &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		*pgs_mask_p |= gather_mask;
+	}
+
+	/*
+	 * If there is a non-conflicting partitionwise specification, enforce.
+	 *
+	 * To force a partitionwise join, we disable all the ordinary means of
+	 * performing a join, and instead only Append and MergeAppend paths here.
+	 * To prevent one, we just disable Append and MergeAppend.  Note that we
+	 * must not unset PGS_CONSIDER_PARTITIONWISE even when we don't want a
+	 * partitionwise join here, because we might want one at a higher level
+	 * that is constructing using paths from this level.
+	 */
+	if (partitionwise_outcome != 0 && !partitionwise_conflict)
+	{
+		if (partitionwise_outcome > 0)
+			*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) |
+				PGS_APPEND | PGS_MERGE_APPEND | PGS_CONSIDER_PARTITIONWISE;
+		else
+			*pgs_mask_p &= ~(PGS_APPEND | PGS_MERGE_APPEND);
+	}
+}
+
+/*
+ * Enforce restrictions on the join order or join method.
+ *
+ * Note that, although it is possible to view PARTITIONWISE advice as
+ * controlling the join method, we can't enforce it here, because the code
+ * path where this executes only deals with join paths that are built directly
+ * from a single outer path and a single inner path.
+ */
+static void
+pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
+									char *plan_name,
+									pgpa_join_state *pjs)
+{
+	int			i = -1;
+	Bitmapset  *jo_permit_indexes = NULL;
+	Bitmapset  *jo_deny_indexes = NULL;
+	Bitmapset  *jm_indexes = NULL;
+	bool		jm_conflict = false;
+	uint32		join_mask = 0;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->join_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->join_entries[i];
+		uint32		my_join_mask;
+
+		/* Handle join order advice. */
+		if (entry->tag == PGPA_TAG_JOIN_ORDER)
+		{
+			if (pgpa_join_order_permits_join(pjs->outer_count,
+											 pjs->inner_count,
+											 pjs->rids,
+											 entry))
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			else
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			continue;
+		}
+
+		/* Handle join strategy advice. */
+		my_join_mask = pgpa_join_strategy_mask_from_advice_tag(entry->tag);
+		if (my_join_mask != 0)
+		{
+			bool		permit;
+			bool		restrict_method;
+
+			if (entry->tag == PGPA_TAG_FOREIGN_JOIN)
+				permit = pgpa_opaque_join_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			else
+				permit = pgpa_join_method_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			if (!permit)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				jm_indexes = bms_add_member(jo_permit_indexes, i);
+				if (join_mask != 0 && join_mask != my_join_mask)
+					jm_conflict = true;
+				join_mask = my_join_mask;
+			}
+			continue;
+		}
+
+		/* Handle semijoin uniqueness advice. */
+		if (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE ||
+			entry->tag == PGPA_TAG_SEMIJOIN_NON_UNIQUE)
+		{
+			bool		advice_unique;
+			bool		jt_unique;
+			bool		jt_non_unique;
+			bool		restrict_method;
+
+			/* Advice wants to unique-ify and use a regular join? */
+			advice_unique = (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE);
+
+			/* Planner is trying to unique-ify and use a regular join? */
+			jt_unique = (jointype == JOIN_UNIQUE_INNER ||
+						 jointype == JOIN_UNIQUE_OUTER);
+
+			/* Planner is trying a semi-join, without unique-ifying? */
+			jt_non_unique = (jointype == JOIN_SEMI ||
+							 jointype == JOIN_RIGHT_SEMI);
+
+			/*
+			 * These advice tags behave very much like join method advice, in
+			 * that they want the inner side of the semijoin to match the
+			 * relations listed in the advice. Hence, we test whether join
+			 * method advice would enforce a join order restriction here, and
+			 * disallow the join if not.
+			 *
+			 * XXX. Think harder about right semijoins.
+			 */
+			if (!pgpa_join_method_permits_join(pjs->outer_count,
+											   pjs->inner_count,
+											   pjs->rids,
+											   entry,
+											   &restrict_method))
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				if (!jt_unique && !jt_non_unique)
+				{
+					/*
+					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
+					 * or SJ_NON_UNIQUE can be applied.
+					 */
+					entry->flags |= PGPA_TE_INAPPLICABLE;
+				}
+				else if (advice_unique != jt_unique)
+					jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			}
+			continue;
+		}
+	}
+
+	/*
+	 * If the advice indicates both that this join order is permissible and
+	 * also that it isn't, then mark advice related to the join order as
+	 * conflicting.
+	 */
+	if (jo_permit_indexes != NULL && jo_deny_indexes != NULL)
+	{
+		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * If more than one join method specification is relevant here and they
+	 * differ, mark them all as conflicting.
+	 */
+	if (jm_conflict)
+		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
+							 PGPA_TE_CONFLICTING);
+
+	/*
+	 * If we were advised to deny this join order, then do so. However, if we
+	 * were also advised to permit it, then do nothing, since the advice
+	 * conflicts.
+	 */
+	if (jo_deny_indexes != NULL && jo_permit_indexes == NULL)
+		*pgs_mask_p = 0;
+
+	/*
+	 * If we were advised to restrict the join method, then do so. However, if
+	 * we got conflicting join method advice or were also advised to reject
+	 * this join order completely, then instead do nothing.
+	 */
+	if (join_mask != 0 && !jm_conflict && jo_deny_indexes == NULL)
+		*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) | join_mask;
+}
+
+/*
+ * Translate an advice tag into a path generation strategy mask.
+ *
+ * This function can be called with tag types that don't represent join
+ * strategies. In such cases, we just return 0, which can't be confused with
+ * a valid mask.
+ */
+static uint64
+pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag)
+{
+	switch (tag)
+	{
+		case PGPA_TAG_FOREIGN_JOIN:
+			return PGS_FOREIGNJOIN;
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return PGS_MERGEJOIN_PLAIN;
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return PGS_MERGEJOIN_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return PGS_NESTLOOP_PLAIN;
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return PGS_NESTLOOP_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return PGS_NESTLOOP_MEMOIZE;
+		case PGPA_TAG_HASH_JOIN:
+			return PGS_HASHJOIN;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Does a certain item of join order advice permit a certain join?
+ */
+static bool
+pgpa_join_order_permits_join(int outer_count, int inner_count,
+							 pgpa_identifier *rids,
+							 pgpa_trove_entry *entry)
+{
+	bool		loop = true;
+	bool		sublist = false;
+	int			length;
+	int			outer_length;
+	pgpa_advice_target *target = entry->target;
+	pgpa_advice_target *prefix_target;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * Find the innermost sublist that contains all keys; if no sublist does,
+	 * then continue processing with the toplevel list.
+	 *
+	 * For example, if the advice says JOIN_ORDER(t1 t2 (t3 t4 t5)), then we
+	 * should evaluate joins that only involve t3, t4, and/or t5 against the
+	 * (t3 t4 t5) sublist, and others against the full list.
+	 *
+	 * Note that (1) outermost sublist is always ordered and (2) whenever we
+	 * zoom into an unordered sublist, we instantly accept the proposed join.
+	 * If the advice says JOIN_ORDER(t1 t2 {t3 t4 t5}), any approach to
+	 * joining t3, t4, and/or t5 is acceptable.
+	 */
+	while (loop)
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+		loop = false;
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_itm_type itm;
+
+			if (child_target->ttype == PGPA_TARGET_IDENTIFIER)
+				continue;
+
+			itm = pgpa_identifiers_match_target(outer_count + inner_count,
+												rids, child_target);
+			if (itm == PGPA_ITM_EQUAL || itm == PGPA_ITM_KEYS_ARE_SUBSET)
+			{
+				if (child_target->ttype == PGPA_TARGET_ORDERED_LIST)
+				{
+					target = child_target;
+					sublist = true;
+					loop = true;
+					break;
+				}
+				else
+				{
+					Assert(child_target->ttype == PGPA_TARGET_UNORDERED_LIST);
+					return true;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Try to find a prefix of the selected join order list that is exactly
+	 * equal to the outer side of the proposed join.
+	 */
+	length = list_length(target->children);
+	prefix_target = palloc0_object(pgpa_advice_target);
+	prefix_target->ttype = PGPA_TARGET_ORDERED_LIST;
+	for (outer_length = 1; outer_length <= length; ++outer_length)
+	{
+		pgpa_itm_type itm;
+
+		/* Avoid leaking memory in every loop iteration. */
+		if (prefix_target->children != NULL)
+			list_free(prefix_target->children);
+		prefix_target->children = list_copy_head(target->children,
+												 outer_length);
+
+		/* Search, hoping to find an exact match. */
+		itm = pgpa_identifiers_match_target(outer_count, rids, prefix_target);
+		if (itm == PGPA_ITM_EQUAL)
+			break;
+
+		/*
+		 * If the prefix of the join order list that we're considering
+		 * includes some but not all of the outer rels, we can make the prefix
+		 * longer to find an exact match. But the advice hasn't mentioned
+		 * everything that's part of our outer rel yet, but has mentioned
+		 * things that are not, then this join doesn't match the join order
+		 * list.
+		 */
+		if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+			return false;
+	}
+
+	/*
+	 * If the previous looped stopped before the prefix_target included the
+	 * entire join order list, then the next member of the join order list
+	 * must exactly match the inner side of the join.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), if the outer side of the
+	 * current join includes only t1, then the inner side must be exactly t2;
+	 * if the outer side includes both t1 and t2, then the inner side must
+	 * include exactly t3, t4, and t5.
+	 */
+	if (outer_length < length)
+	{
+		pgpa_advice_target *inner_target;
+		pgpa_itm_type itm;
+
+		inner_target = list_nth(target->children, outer_length);
+
+		itm = pgpa_identifiers_match_target(inner_count, rids + outer_count,
+											inner_target);
+
+		/*
+		 * Before returning, consider whether we need to mark this entry as
+		 * fully matched. If we found every item but one on the lefthand side
+		 * of the join and the last item on the righthand side of the join,
+		 * then the answer is yes.
+		 */
+		if (outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
+			entry->flags |= PGPA_TE_MATCH_FULL;
+
+		return (itm == PGPA_ITM_EQUAL);
+	}
+
+	/*
+	 * If we get here, then the outer side of the join includes the entirety
+	 * of the join order list. In this case, we behave differently depending
+	 * on whether we're looking at the top-level join order list or sublist.
+	 * At the top-level, we treat the specified list as mandating that the
+	 * actual join order has the given list as a prefix, but a sublist
+	 * requires an exact match.
+	 *
+	 * Exmaple: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), we must start by joining
+	 * all five of those relations and in that sequence, but once that is
+	 * done, it's OK to join any other rels that are part of the join problem.
+	 * This allows a user to specify the driving table and perhaps the first
+	 * few things to which it should be joined while leaving the rest of the
+	 * join order up the optimizer. But it seems like it would be surprising,
+	 * given that specification, if the user could add t6 to the (t3 t4 t5)
+	 * sub-join, so we don't allow that. If we did want to allow it, the logic
+	 * earlier in this function would require substantial adjustment: we could
+	 * allow the t3-t4-t5-t6 join to be built here, but the next step of
+	 * joining t1-t2 to the result would still be rejected.
+	 */
+	return !sublist;
+}
+
+/*
+ * Does a certain item of join method advice permit a certain join?
+ *
+ * Advice such as HASH_JOIN((x y)) means that there should be a hash join with
+ * exactly x and y on the inner side. Obviously, this means that if we are
+ * considering a join with exactly x and y on the inner side, we should enforce
+ * the use of a hash join. However, it also means that we must reject some
+ * incompatible join orders entirely.  For example, a join with exactly x
+ * and y on the outer side shouldn't be allowed, because such paths might win
+ * over the advice-driven path on cost.
+ *
+ * To accommodate these requirements, this function returns true if the join
+ * should be allowed and false if it should not. Furthermore, *restrict_method
+ * is set to true if the join method should be enforced and false if not.
+ */
+static bool
+pgpa_join_method_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	/*
+	 * If our inner rel mentions exactly the same relations as the advice
+	 * target, allow the join and enforce the join method restriction.
+	 *
+	 * If our inner rel mentions a superset of the target relations, allow the
+	 * join. The join we care about has already taken place, and this advice
+	 * imposes no further restrictions.
+	 */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If our outer rel mentions a supserset of the relations in the advice
+	 * target, no restrictions apply. The join we care has already taken
+	 * place, and this advice imposes no further restrictions.
+	 *
+	 * On the other hand, if our outer rel mentions exactly the relations
+	 * mentioned in the advice target, the planner is trying to reverse the
+	 * sides of the join as compared with our desired outcome. Reject that.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+	else if (outer_itm == PGPA_ITM_EQUAL)
+		return false;
+
+	/*
+	 * If the advice target mentions only a single relation, the test below
+	 * cannot ever pass, so save some work by exiting now.
+	 */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+		return false;
+
+	/*
+	 * If everything in the joinrel is appears in the advice target, we're
+	 * below the level of the join we want to control.
+	 *
+	 * For example, HASH_JOIN((x y)) doesn't restrict how x and y can be
+	 * joined.
+	 *
+	 * This lookup shouldn't return PGPA_ITM_DISJOINT, because any such advice
+	 * should not have been returned from the trove in the first place.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've already permitted all allowable cases, so reject this.
+	 *
+	 * If we reach this point, then the advice overlaps with this join but
+	 * isn't entirely contained within either side, and there's also at least
+	 * one relation present in the join that isn't mentioned by the advice.
+	 *
+	 * For instance, in the HASH_JOIN((x y)) example, we would reach here if x
+	 * were on one side of the join, y on the other, and at least one of the
+	 * two sides also included some other relation, say t. In that case,
+	 * accepting this join would allow the (x y t) joinrel to contain
+	 * non-disabled paths that do not put (x y) on the inner side of a hash
+	 * join; we could instead end up with something like (x JOIN t) JOIN y.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning an opaque join permit a certain join?
+ *
+ * By an opaque join, we mean one where the exact mechanism by which the
+ * join is performed is not visible to PostgreSQL. Currently this is the
+ * case only for foreign joins: FOREIGN_JOIN((x y z)) means that x, y, and
+ * z are joined on the remote side, but we know nothing about the join order
+ * or join methods used over there.
+ */
+static bool
+pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	if (join_itm == PGPA_ITM_EQUAL)
+	{
+		/*
+		 * We have an exact match, and should therefore allow the join and
+		 * enforce the use of the relevant opaque join method.
+		 */
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+	{
+		/*
+		 * If join_itm == PGPA_ITM_TARGETS_ARE_SUBSET, then the join we care
+		 * about has already taken place and no further restrictions apply.
+		 *
+		 * If join_itm == PGPA_ITM_KEYS_ARE_SUBSET, we're still building up to
+		 * the join we care about and have not introduced any extraneous
+		 * relations not named in the advice. Note that ForeignScan paths for
+		 * joins are built up from ForeignScan paths from underlying joins and
+		 * scans, so we must not disable this join when considering a subset
+		 * of the relations we ultimately want.
+		 */
+		return true;
+	}
+
+	/*
+	 * The advice overlaps the join, but at least one relation is present in
+	 * the join that isn't mentioned by the advice. We want to disable such
+	 * paths so that we actually push down the join as intended.
+	 */
+	return false;
+}
+
+/*
+ * Apply scan advice to a RelOptInfo.
+ *
+ * XXX. For bitmap heap scans, we're just ignoring the index information from
+ * the advice. That's not cool.
+ */
+static void
+pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+							   pgpa_trove_entry *scan_entries,
+							   Bitmapset *scan_indexes,
+							   pgpa_trove_entry *rel_entries,
+							   Bitmapset *rel_indexes)
+{
+	bool		gather_conflict = false;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	int			i = -1;
+	pgpa_trove_entry *scan_entry = NULL;
+	int			flags;
+	bool		scan_type_conflict = false;
+	Bitmapset  *scan_type_indexes = NULL;
+	Bitmapset  *scan_type_rel_indexes = NULL;
+	uint64		gather_mask = 0;
+	uint64		scan_type = 0;
+
+	/* 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;
+
+		/* Translate our advice tags to a scan strategy advice value. */
+		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+			my_scan_type = PGS_BITMAPSCAN;
+		else if (my_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN)
+			my_scan_type = PGS_INDEXONLYSCAN | PGS_CONSIDER_INDEXONLY;
+		else if (my_entry->tag == PGPA_TAG_INDEX_SCAN)
+			my_scan_type = PGS_INDEXSCAN;
+		else if (my_entry->tag == PGPA_TAG_SEQ_SCAN)
+			my_scan_type = PGS_SEQSCAN;
+		else if (my_entry->tag == PGPA_TAG_TID_SCAN)
+			my_scan_type = PGS_TIDSCAN;
+
+		/*
+		 * If this is understandable scan advice, hang on to the entry, the
+		 * inferred scan type type, and the index at which we found it.
+		 *
+		 * Also make a note if we see conflicting scan type advice. Note that
+		 * we regard two index specifications as conflicting unless they match
+		 * exactly. In theory, perhaps we could regard INDEX_SCAN(a c) and
+		 * 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 (scan_type != 0 && scan_type != my_scan_type)
+				scan_type_conflict = true;
+			if (!scan_type_conflict && scan_entry != NULL &&
+				my_entry->target->itarget != NULL &&
+				scan_entry->target->itarget != NULL &&
+				!pgpa_index_targets_equal(scan_entry->target->itarget,
+										  my_entry->target->itarget))
+				scan_type_conflict = true;
+			scan_entry = my_entry;
+			scan_type = my_scan_type;
+			scan_type_indexes = bms_add_member(scan_type_indexes, i);
+		}
+	}
+
+	/* Scrutinize available gather-related and partitionwise advice. */
+	i = -1;
+	while ((i = bms_next_member(rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *my_entry = &rel_entries[i];
+		uint64		my_gather_mask = 0;
+		bool		just_one_rel;
+
+		just_one_rel = my_entry->target->ttype == PGPA_TARGET_IDENTIFIER
+			|| list_length(my_entry->target->children) == 1;
+
+		/*
+		 * PARTITIONWISE behaves like a scan type, except that if there's more
+		 * than one relation targeted, it has no effect at this level.
+		 */
+		if (my_entry->tag == PGPA_TAG_PARTITIONWISE)
+		{
+			if (just_one_rel)
+			{
+				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
+
+				if (scan_type != 0 && scan_type != my_scan_type)
+					scan_type_conflict = true;
+				scan_entry = my_entry;
+				scan_type = my_scan_type;
+				scan_type_rel_indexes =
+					bms_add_member(scan_type_rel_indexes, i);
+			}
+			continue;
+		}
+
+		/*
+		 * GATHER and GATHER_MERGE applied to a single rel mean that we should
+		 * use the correspondings strategy here, while applying either to more
+		 * than one rel means we should not use those strategies here, but
+		 * rather at the level of the joinrel that corresponds to what was
+		 * specified. NO_GATHER can only be applied to single rels.
+		 *
+		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
+		 * equivalent to allowing the non-use of either form of Gather here.
+		 */
+		if (my_entry->tag == PGPA_TAG_GATHER |
+			my_entry->tag == PGPA_TAG_GATHER_MERGE)
+		{
+			if (!just_one_rel)
+				my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			else if (my_entry->tag == PGPA_TAG_GATHER)
+				my_gather_mask = PGS_GATHER;
+			else
+				my_gather_mask = PGS_GATHER_MERGE;
+		}
+		else if (my_entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			Assert(just_one_rel);
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (just_one_rel)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+	}
+
+	/* Enforce choice of index. */
+	if (scan_entry != NULL && !scan_type_conflict &&
+		(scan_entry->tag == PGPA_TAG_INDEX_SCAN ||
+		 scan_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN))
+	{
+		pgpa_index_target *itarget = scan_entry->target->itarget;
+		IndexOptInfo *matched_index = NULL;
+
+		Assert(itarget->itype == PGPA_INDEX_NAME);
+
+		foreach_node(IndexOptInfo, index, rel->indexlist)
+		{
+			char	   *relname = get_rel_name(index->indexoid);
+			Oid			nspoid = get_rel_namespace(index->indexoid);
+			char	   *relnamespace = get_namespace_name(nspoid);
+
+			if (strcmp(itarget->indname, relname) == 0 &&
+				(itarget->indnamespace == NULL ||
+				 strcmp(itarget->indnamespace, relnamespace) == 0))
+			{
+				matched_index = index;
+				break;
+			}
+		}
+
+		if (matched_index == NULL)
+		{
+			/* Don't force the scan type if the index doesn't exist. */
+			scan_type = 0;
+
+			/* Mark advice as inapplicable. */
+			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
+								 PGPA_TE_INAPPLICABLE);
+		}
+		else
+		{
+			/* Retain this index and discard the rest. */
+			rel->indexlist = list_make1(matched_index);
+		}
+	}
+
+	/*
+	 * Mark all the scan method entries as fully matched; and if they specify
+	 * different things, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	if (scan_type_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
+	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched. Mark
+	 * the ones that included this relation as a target by itself as fully
+	 * matched. If there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
+
+	/* If there is a non-conflicting scan specification, enforce it. */
+	if (scan_type != 0 && !scan_type_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
+			  PGS_CONSIDER_INDEXONLY);
+		rel->pgs_mask |= scan_type;
+	}
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		rel->pgs_mask |= gather_mask;
+	}
+}
+
+/*
+ * Add feedback entries to for one trove slice to the provided list and
+ * return the resulting list.
+ *
+ * Feedback entries are generated from the trove entry's flags. It's assumed
+ * that the caller has already set all relevant flags with the exception of
+ * PGPA_TE_FAILED. We set that flag here if appropriate.
+ */
+static List *
+pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+							 pgpa_trove_lookup_type type,
+							 pgpa_identifier *rt_identifiers,
+							 pgpa_plan_walker_context *walker)
+{
+	pgpa_trove_entry *entries;
+	int			nentries;
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	pgpa_trove_lookup_all(trove, type, &entries, &nentries);
+	for (int i = 0; i < nentries; ++i)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+		DefElem    *item;
+
+		/*
+		 * If this entry was fully matched, check whether generating advice
+		 * from this plan would produce such an entry. If not, label the entry
+		 * as failed.
+		 */
+		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+			!pgpa_walker_would_advise(walker, rt_identifiers,
+									  entry->tag, entry->target))
+			entry->flags |= PGPA_TE_FAILED;
+
+		item = makeDefElem(pgpa_cstring_trove_entry(entry),
+						   (Node *) makeInteger(entry->flags), -1);
+		list = lappend(list, item);
+	}
+
+	return list;
+}
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * Fast hash function for a key consisting of an RTI and plan name.
+ */
+static uint32
+pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	hs.accum = key.rti;
+	fasthash_combine(&hs);
+
+	/* plan_name can be NULL */
+	if (key.plan_name == NULL)
+		sp_len = 0;
+	else
+		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+
+	/* hashfn_unstable.h recommends using string length as tweak */
+	return fasthash_final32(&hs, sp_len);
+}
+
+#endif
+
+/*
+ * Save the range table identifier for one relation for future cross-checking.
+ */
+static void
+pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
+					 RelOptInfo *rel)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_checker_key key;
+	pgpa_ri_checker *check;
+	pgpa_identifier rid;
+	const char *rid_string;
+	bool		found;
+
+	key.rti = bms_singleton_member(rel->relids);
+	key.plan_name = root->plan_name;
+	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
+	rid_string = pgpa_identifier_string(&rid);
+	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
+	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
+	check->rid_string = rid_string;
+#endif
+}
+
+/*
+ * 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_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	pgpa_ri_check_iterator it;
+	pgpa_ri_checker *check;
+
+	/* 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. */
+	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
+	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	{
+		int			rtoffset = 0;
+		const char *rid_string;
+		Index		flat_rti;
+
+		/*
+		 * 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.
+		 */
+		if (check->key.plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				/*
+				 * If rtinfo->dummy is set, then the subquery's range table
+				 * will only have been partially copied to the final range
+				 * table. Specifically, only RTE_RELATION entries and
+				 * 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
+				 * in the final rtable.
+				 *
+				 * With more complex logic, we could work around that problem
+				 * by remembering the whole contents of the subquery's rtable
+				 * during planning, determining which of those would have been
+				 * copied to the final rtable, and matching them up. But it
+				 * doesn't seem like a worthwhile endeavor for right now,
+				 * because RTIs from such subqueries won't appear in the plan
+				 * tree itself, just in the range table. Hence, we can neither
+				 * generate nor accept advice for them.
+				 */
+				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+					&& !rtinfo->dummy)
+				{
+					rtoffset = rtinfo->rtoffset;
+					Assert(rtoffset > 0);
+					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;
+		}
+
+		/*
+		 * check->key.rti is the RTI that we saw prior to range-table
+		 * flattening, so we must add the appropriate RT offset to get the
+		 * final RTI.
+		 */
+		flat_rti = check->key.rti + rtoffset;
+		Assert(flat_rti <= list_length(pstmt->rtable));
+
+		/* Assert that the string we compute now matches the previous one. */
+		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
+		Assert(strcmp(rid_string, check->rid_string) == 0);
+	}
+#endif
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
new file mode 100644
index 00000000000..7d40b910b00
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -0,0 +1,17 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.h
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_PLANNER_H
+#define PGPA_PLANNER_H
+
+extern void pgpa_planner_install_hooks(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
new file mode 100644
index 00000000000..dbd7c99e4c2
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -0,0 +1,278 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.c
+ *	  analysis of scans in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+
+static pgpa_scan *pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								 pgpa_scan_strategy strategy,
+								 Bitmapset *relids,
+								 bool beneath_any_gather);
+
+
+static Bitmapset *filter_out_join_relids(Bitmapset *relids, List *rtable);
+static RTEKind unique_nonjoin_rtekind(Bitmapset *relids, List *rtable);
+
+/*
+ * Build a pgpa_scan object for a Plan node and update the plan walker
+ * context as appopriate.  If this is an Append or MergeAppend scan, also
+ * build pgpa_scan for any scans that were consolidated into this one by
+ * Append/MergeAppend pull-up.
+ *
+ * If there is at least one ElidedNode for this plan node, pass the uppermost
+ * one as elided_node, else pass NULL.
+ *
+ * Set the 'beneath_any_gather' node if we are underneath a Gather or
+ * Gather Merge node.
+ *
+ * Set the 'within_join_problem' flag if we're inside of a join problem and
+ * not otherwise.
+ */
+pgpa_scan *
+pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+				ElidedNode *elided_node,
+				bool beneath_any_gather, bool within_join_problem)
+{
+	pgpa_scan_strategy strategy = PGPA_SCAN_ORDINARY;
+	Bitmapset  *relids = NULL;
+	int			rti = -1;
+	List	   *child_append_relid_sets = NIL;
+
+	if (elided_node != NULL)
+	{
+		NodeTag		elided_type = elided_node->elided_type;
+
+		/*
+		 * If setrefs processing elided an Append or MergeAppend node that had
+		 * only one surviving child, then this is a partitionwise "scan" --
+		 * which may really be a partitionwise join, but there's no need to
+		 * distinguish.
+		 *
+		 * If it's a trivial SubqueryScan that was elided, then this is an
+		 * "ordinary" scan i.e. one for which we need to generate advice
+		 * because the planner has not made any meaningful choice.
+		 */
+		relids = elided_node->relids;
+		if (elided_type == T_Append || elided_type == T_MergeAppend)
+			strategy = PGPA_SCAN_PARTITIONWISE;
+		else
+			strategy = PGPA_SCAN_ORDINARY;
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+	{
+		relids = bms_make_singleton(rti);
+
+		switch (nodeTag(plan))
+		{
+			case T_SeqScan:
+				strategy = PGPA_SCAN_SEQ;
+				break;
+			case T_BitmapHeapScan:
+				strategy = PGPA_SCAN_BITMAP_HEAP;
+				break;
+			case T_IndexScan:
+				strategy = PGPA_SCAN_INDEX;
+				break;
+			case T_IndexOnlyScan:
+				strategy = PGPA_SCAN_INDEX_ONLY;
+				break;
+			case T_TidScan:
+			case T_TidRangeScan:
+				strategy = PGPA_SCAN_TID;
+				break;
+			default:
+
+				/*
+				 * This case includes a ForeignScan targeting a single
+				 * relation; no other strategy is possible in that case, but
+				 * see below, where things are different in multi-relation
+				 * cases.
+				 */
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+	}
+	else if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		switch (nodeTag(plan))
+		{
+			case T_ForeignScan:
+
+				/*
+				 * If multiple relations are being targeted by a single
+				 * foreign scan, then the foreign join has been pushed to the
+				 * remote side, and we want that to be reflected in the
+				 * generated advice.
+				 */
+				strategy = PGPA_SCAN_FOREIGN;
+				break;
+			case T_Append:
+
+				/*
+				 * Append nodes can represent partitionwise scans of a a
+				 * relation, but when they implement a set operation, they are
+				 * just ordinary scans.
+				 */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((Append *) plan)->child_append_relid_sets;
+				break;
+			case T_MergeAppend:
+				/* Some logic here as for Append, above. */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((MergeAppend *) plan)->child_append_relid_sets;
+				break;
+			default:
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+
+	/*
+	 * If this is an Append or MergeAppend node into which subordinate Append
+	 * or MergeAppend paths were merged, each of those merged paths is
+	 * effectively another scan for which we need to account.
+	 */
+	foreach_node(Bitmapset, child_relids, child_append_relid_sets)
+	{
+		Bitmapset  *child_nonjoin_relids;
+
+		child_nonjoin_relids = filter_out_join_relids(child_relids,
+													  walker->pstmt->rtable);
+		(void) pgpa_make_scan(walker, plan, strategy,
+							  child_nonjoin_relids,
+							  beneath_any_gather);
+	}
+
+	/*
+	 * If this plan node has no associated RTIs, it's not a scan. When the
+	 * 'within_join_problem' flag is set, that's unexpected, so throw an
+	 * error, else return quietly.
+	 */
+	if (relids == NULL)
+	{
+		if (within_join_problem)
+			elog(ERROR, "plan node has no RTIs: %d", (int) nodeTag(plan));
+		return NULL;
+	}
+
+	return pgpa_make_scan(walker, plan, strategy, relids, beneath_any_gather);
+}
+
+/*
+ * Create a single pgpa_scan object and update the pgpa_plan_walker_context.
+ */
+static pgpa_scan *
+pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+			   pgpa_scan_strategy strategy, Bitmapset *relids,
+			   bool beneath_any_gather)
+{
+	pgpa_scan  *scan;
+
+	/* Create the scan object. */
+	scan = palloc(sizeof(pgpa_scan));
+	scan->plan = plan;
+	scan->strategy = strategy;
+	scan->relids = relids;
+	scan->beneath_any_gather = beneath_any_gather;
+
+	/* Add it to the appropriate list. */
+	walker->scans[scan->strategy] = lappend(walker->scans[scan->strategy],
+											scan);
+
+	/*
+	 * We intend to emit NO_GATHER() advice for each scan that doesn't appear
+	 * beneath a Gather or Gather Merge node, but we need not do this for
+	 * partitionwise scans, because emitting NO_GATHER() for the child scans
+	 * suffices.
+	 */
+	if (!scan->beneath_any_gather && scan->strategy != PGPA_SCAN_PARTITIONWISE)
+		walker->no_gather_scans = bms_add_members(walker->no_gather_scans,
+												  scan->relids);
+
+	return scan;
+}
+
+/*
+ * Determine the unique rtekind of a set of relids.
+ */
+static RTEKind
+unique_nonjoin_rtekind(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	bool		first = true;
+	RTEKind		rtekind;
+
+	Assert(relids != NULL);
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		if (first)
+		{
+			rtekind = rte->rtekind;
+			first = false;
+		}
+		else if (rtekind != rte->rtekind)
+			elog(ERROR, "rtekind mismatch: %d vs. %d",
+				 rtekind, rte->rtekind);
+	}
+
+	if (first)
+		elog(ERROR, "no non-RTE_JOIN RTEs found");
+
+	return rtekind;
+}
+
+/*
+ * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
+ */
+static Bitmapset *
+filter_out_join_relids(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	Bitmapset  *result = NULL;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind != RTE_JOIN)
+			result = bms_add_member(result, rti);
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_scan.h b/contrib/pg_plan_advice/pgpa_scan.h
new file mode 100644
index 00000000000..90a08b41c5b
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.h
@@ -0,0 +1,86 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.h
+ *	  analysis of scans in Plan trees
+ *
+ * For purposes of this module, a "scan" includes (1) single plan nodes that
+ * scan multiple RTIs, such as a degenerate Result node that replaces what
+ * would otherwise have been a join, and (2) Append and MergeAppend nodes
+ * implementing a partitionwise scan or a partitionwise join. Said
+ * differently, scans are the leaves of the join tree for a single join
+ * problem.
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_SCAN_H
+#define PGPA_SCAN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+
+/*
+ * Scan strategies.
+ *
+ * PGPA_SCAN_ORDINARY is any scan strategy that isn't interesting to us
+ * because there is no meaningful planner decision involved. For example,
+ * the only way to scan a subquery is a SubqueryScan, and the only way to
+ * scan a VALUES construct is a ValuesScan. We need not care exactly which
+ * type of planner node was used in such cases, because the same thing will
+ * happen when replanning.
+ *
+ * PGPA_SCAN_ORDINARY also includes Result nodes that correspond to scans
+ * or even joins that are proved empty. We don't know whether or not the scan
+ * or join will still be provably empty at replanning time, but if it is,
+ * then no scan-type advice is needed, and if it's not, we can't recommend
+ * a scan type based on the current plan.
+ *
+ * PGPA_SCAN_PARTITIONWISE also lumps together scans and joins: this can
+ * be either a partitionwise scan of a partitioned table or a partitionwise
+ * join between several partitioned tables. Note that all decisions about
+ * whether or not to use partitionwise join are meaningful: no matter what
+ * we decided this time, we could do more or fewer things partitionwise the
+ * next time.
+ *
+ * PGPA_SCAN_FOREIGN is only used when there's more than one relation involved;
+ * a single-table foreign scan is classified as ordinary, since there is no
+ * decision to make in that case.
+ *
+ * Other scan strategies map one-to-one to plan nodes.
+ */
+typedef enum
+{
+	PGPA_SCAN_ORDINARY = 0,
+	PGPA_SCAN_SEQ,
+	PGPA_SCAN_BITMAP_HEAP,
+	PGPA_SCAN_FOREIGN,
+	PGPA_SCAN_INDEX,
+	PGPA_SCAN_INDEX_ONLY,
+	PGPA_SCAN_PARTITIONWISE,
+	PGPA_SCAN_TID
+	/* update NUM_PGPA_SCAN_STRATEGY if you add anything here */
+} pgpa_scan_strategy;
+
+#define NUM_PGPA_SCAN_STRATEGY	((int) PGPA_SCAN_TID + 1)
+
+/*
+ * All of the details we need regarding a scan.
+ */
+typedef struct pgpa_scan
+{
+	Plan	   *plan;
+	pgpa_scan_strategy strategy;
+	Bitmapset  *relids;
+	bool		beneath_any_gather;
+} pgpa_scan;
+
+extern pgpa_scan *pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								  ElidedNode *elided_node,
+								  bool beneath_any_gather,
+								  bool within_join_problem);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scanner.l b/contrib/pg_plan_advice/pgpa_scanner.l
new file mode 100644
index 00000000000..be7d7ba13a6
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scanner.l
@@ -0,0 +1,299 @@
+%top{
+/*
+ * Scanner for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_scanner.l
+ */
+#include "postgres.h"
+
+#include "common/string.h"
+#include "nodes/miscnodes.h"
+#include "parser/scansup.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Extra data that we pass around when during scanning.
+ *
+ * 'litbuf' is used to implement the <xd> exclusive state, which handles
+ * double-quoted identifiers.
+ */
+typedef struct pgpa_yy_extra_type
+{
+	StringInfoData	litbuf;
+} pgpa_yy_extra_type;
+
+}
+
+%{
+/* LCOV_EXCL_START */
+
+#define YY_DECL \
+	extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result, \
+						  char **parse_error_msg_p, yyscan_t yyscanner)
+
+/* No reason to constrain amount of data slurped */
+#define YY_READ_BUF_SIZE 16777216
+
+/* Avoid exit() on fatal scanner errors (a bit ugly -- see yy_fatal_error) */
+#undef fprintf
+#define fprintf(file, fmt, msg)  fprintf_to_ereport(fmt, msg)
+
+static void
+fprintf_to_ereport(const char *fmt, const char *msg)
+{
+	ereport(ERROR, (errmsg_internal("%s", msg)));
+}
+%}
+
+%option reentrant
+%option bison-bridge
+%option 8bit
+%option never-interactive
+%option nodefault
+%option noinput
+%option nounput
+%option noyywrap
+%option noyyalloc
+%option noyyrealloc
+%option noyyfree
+%option warn
+%option prefix="pgpa_yy"
+%option extra-type="pgpa_yy_extra_type *"
+
+/*
+ * What follows is a severely stripped-down version of the core scanner. We
+ * only care about recognizing identifiers with or without identifier quoting
+ * (i.e. double-quoting), decimal integers, and a small handful of other
+ * things. Keep these rules in sync with src/backend/parser/scan.l. As in that
+ * file, we use an exclusive state called 'xc' for C-style comments, and an
+ * exclusive state called 'xd' for double-quoted identifiers.
+ */
+%x xc
+%x xd
+
+ident_start		[A-Za-z\200-\377_]
+ident_cont		[A-Za-z\200-\377_0-9\$]
+
+identifier		{ident_start}{ident_cont}*
+
+decdigit		[0-9]
+decinteger		{decdigit}(_?{decdigit})*
+
+space			[ \t\n\r\f\v]
+whitespace		{space}+
+
+dquote			\"
+xdstart			{dquote}
+xdstop			{dquote}
+xddouble		{dquote}{dquote}
+xdinside		[^"]+
+
+xcstart			\/\*
+xcstop			\*+\/
+xcinside		[^*/]+
+
+%%
+
+{whitespace}	{ /* ignore */ }
+
+{identifier}	{
+					char   *str;
+					bool	fail;
+					pgpa_advice_tag_type	tag;
+
+					/*
+					 * Unlike the core scanner, we don't truncate identifiers
+					 * here. There is no obvious reason to do so.
+					 */
+					str = downcase_identifier(yytext, yyleng, false, false);
+					yylval->str = str;
+
+					/*
+					 * If it's not a tag, just return TOK_IDENT; else, return
+					 * a token type based on how further parsing should
+					 * proceed.
+					 */
+					tag = pgpa_parse_advice_tag(str, &fail);
+					if (fail)
+						return TOK_IDENT;
+					else if (tag == PGPA_TAG_JOIN_ORDER)
+						return TOK_TAG_JOIN_ORDER;
+					else if (tag == PGPA_TAG_INDEX_SCAN ||
+							 tag == PGPA_TAG_INDEX_ONLY_SCAN)
+						return TOK_TAG_INDEX;
+					else if (tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+						return TOK_TAG_BITMAP;
+					else if (tag == PGPA_TAG_SEQ_SCAN ||
+							 tag == PGPA_TAG_TID_SCAN ||
+							 tag == PGPA_TAG_NO_GATHER)
+						return TOK_TAG_SIMPLE;
+					else
+						return TOK_TAG_GENERIC;
+				}
+
+{decinteger}	{
+					char   *endptr;
+
+					errno = 0;
+					yylval->integer = strtoint(yytext, &endptr, 10);
+					if (*endptr != '\0' || errno == ERANGE)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "integer out of range");
+					return TOK_INTEGER;
+				}
+
+{xcstart}		{
+					BEGIN(xc);
+				}
+
+{xdstart}		{
+					BEGIN(xd);
+					resetStringInfo(&yyextra->litbuf);
+				}
+
+"||"			{ return TOK_OR; }
+
+"&&"			{ return TOK_AND; }
+
+.				{ return yytext[0]; }
+
+<xc>{xcstop}	{
+					BEGIN(INITIAL);
+				}
+
+<xc>{xcinside}	{
+					/* discard multiple characters without slash or asterisk */
+				}
+
+<xc>.			{
+					/*
+					 * Discard any single character. flex prefers longer
+					 * matches, so this rule will never be picked when we could
+					 * have matched xcstop.
+					 *
+					 * NB: At present, we don't bother to support nested
+					 * C-style comments here, but this logic could be extended
+					 * if that restriction poses a problem.
+					 */
+				}
+
+<xc><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated comment");
+				}
+
+<xd>{xdstop}	{
+					BEGIN(INITIAL);
+					yylval->str = pstrdup(yyextra->litbuf.data);
+					return TOK_IDENT;
+				}
+
+<xd>{xddouble}	{
+					appendStringInfoChar(&yyextra->litbuf, '"');
+				}
+
+<xd>{xdinside}	{
+					appendBinaryStringInfo(&yyextra->litbuf, yytext, yyleng);
+				}
+
+<xd><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated quoted identifier");
+				}
+
+%%
+
+/* LCOV_EXCL_STOP */
+
+/*
+ * Handler for errors while scanning or parsing advice.
+ *
+ * bison passes the error message to us via 'message', and the context is
+ * available via the 'yytext' macro. We assemble those values into a final
+ * error text and then arrange to pass it back to the caller of pgpa_yyparse()
+ * by storing it into *parse_error_msg_p.
+ */
+void
+pgpa_yyerror(List **result, char **parse_error_msg_p, yyscan_t yyscanner,
+			 const char *message)
+{
+	struct yyguts_t *yyg = (struct yyguts_t *) yyscanner;	/* needed for yytext
+															 * macro */
+
+
+	/* report only the first error in a parse operation */
+	if (*parse_error_msg_p)
+		return;
+
+	if (yytext[0])
+		*parse_error_msg_p = psprintf("%s at or near \"%s\"", message, yytext);
+	else
+		*parse_error_msg_p = psprintf("%s at end of input", message);
+}
+
+/*
+ * Initialize the advice scanner.
+ *
+ * This should be called before parsing begins.
+ */
+void
+pgpa_scanner_init(const char *str, yyscan_t *yyscannerp)
+{
+	yyscan_t	yyscanner;
+	pgpa_yy_extra_type	*yyext = palloc0_object(pgpa_yy_extra_type);
+
+	if (yylex_init(yyscannerp) != 0)
+		elog(ERROR, "yylex_init() failed: %m");
+
+	yyscanner = *yyscannerp;
+
+	initStringInfo(&yyext->litbuf);
+	pgpa_yyset_extra(yyext, yyscanner);
+
+	yy_scan_string(str, yyscanner);
+}
+
+
+/*
+ * Shut down the advice scanner.
+ *
+ * This should be called after parsing is complete.
+ */
+void
+pgpa_scanner_finish(yyscan_t yyscanner)
+{
+	yylex_destroy(yyscanner);
+}
+
+/*
+ * Interface functions to make flex use palloc() instead of malloc().
+ * It'd be better to make these static, but flex insists otherwise.
+ */
+
+void *
+yyalloc(yy_size_t size, yyscan_t yyscanner)
+{
+	return palloc(size);
+}
+
+void *
+yyrealloc(void *ptr, yy_size_t size, yyscan_t yyscanner)
+{
+	if (ptr)
+		return repalloc(ptr, size);
+	else
+		return palloc(size);
+}
+
+void
+yyfree(void *ptr, yyscan_t yyscanner)
+{
+	if (ptr)
+		pfree(ptr);
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
new file mode 100644
index 00000000000..a67d56b3c79
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -0,0 +1,487 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.c
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * This name comes from the English expression "trove of advice", which
+ * means a collection of wisdom. This slightly unusual term is chosen to
+ * avoid naming confusion; for example, "collection of advice" would
+ * invite confusion with pgpa_collector.c. Note that, while we don't know
+ * whether the provided advice is actually wise, it's not our job to
+ * question the user's choices.
+ *
+ * The goal of this module is to make it easy to locate the specific
+ * bits of advice that pertain to any given part of a query, or to
+ * determine that there are none.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_trove.h"
+
+#include "common/hashfn_unstable.h"
+
+/*
+ * An advice trove is organized into a series of "slices", each of which
+ * contains information about one topic e.g. scan methods. Each slice consists
+ * of an array of trove entries plus a hash table that we can use to determine
+ * which ones are relevant to a particular part of the query.
+ */
+typedef struct pgpa_trove_slice
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	pgpa_trove_entry *entries;
+	struct pgpa_trove_entry_hash *hash;
+} pgpa_trove_slice;
+
+/*
+ * Scan advice is stored into 'scan'; join advice is stored into 'join'; and
+ * advice that can apply to both cases is stored into 'rel'. This lets callers
+ * ask just for what's relevant. These slices correspond to the possible values
+ * of pgpa_trove_lookup_type.
+ */
+struct pgpa_trove
+{
+	pgpa_trove_slice join;
+	pgpa_trove_slice rel;
+	pgpa_trove_slice scan;
+};
+
+/*
+ * We're going to build a hash table to allow clients of this module to find
+ * relevant advice for a given part of the query quickly. However, we're going
+ * to use only three of the five key fields as hash keys. There are two reasons
+ * for this.
+ *
+ * First, it's allowable to set partition_schema to NULL to match a partition
+ * with the correct name in any schema.
+ *
+ * Second, we expect the "occurrence" and "partition_schema" portions of the
+ * relation identifiers to be mostly uninteresting. Most of the time, the
+ * occurrence field will be 1 and the partition_schema values will all be the
+ * same. Even when there is some variation, the absolute number of entries
+ * that have the same values for all three of these key fields should be
+ * quite small.
+ */
+typedef struct
+{
+	const char *alias_name;
+	const char *partition_name;
+	const char *plan_name;
+} pgpa_trove_entry_key;
+
+typedef struct
+{
+	pgpa_trove_entry_key key;
+	int			status;
+	Bitmapset  *indexes;
+} pgpa_trove_entry_element;
+
+static uint32 pgpa_trove_entry_hash_key(pgpa_trove_entry_key key);
+
+static inline bool
+pgpa_trove_entry_compare_key(pgpa_trove_entry_key a, pgpa_trove_entry_key b)
+{
+	if (strcmp(a.alias_name, b.alias_name) != 0)
+		return false;
+
+	if (!strings_equal_or_both_null(a.partition_name, b.partition_name))
+		return false;
+
+	return true;
+}
+
+#define SH_PREFIX			pgpa_trove_entry
+#define SH_ELEMENT_TYPE		pgpa_trove_entry_element
+#define SH_KEY_TYPE			pgpa_trove_entry_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_trove_entry_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_trove_entry_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static void pgpa_init_trove_slice(pgpa_trove_slice *tslice);
+static void pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+									pgpa_advice_tag_type tag,
+									pgpa_advice_target *target);
+static void pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash,
+								   pgpa_advice_target *target,
+								   int index);
+static Bitmapset *pgpa_trove_slice_lookup(pgpa_trove_slice *tslice,
+										  pgpa_identifier *rid);
+
+/*
+ * Build a trove of advice from a list of advice items.
+ *
+ * Caller can obtain a list of advice items to pass to this function by
+ * calling pgpa_parse().
+ */
+pgpa_trove *
+pgpa_build_trove(List *advice_items)
+{
+	pgpa_trove *trove = palloc_object(pgpa_trove);
+
+	pgpa_init_trove_slice(&trove->join);
+	pgpa_init_trove_slice(&trove->rel);
+	pgpa_init_trove_slice(&trove->scan);
+
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		switch (item->tag)
+		{
+			case PGPA_TAG_JOIN_ORDER:
+				{
+					pgpa_advice_target *target;
+
+					/*
+					 * For most advice types, each element in the top-level
+					 * list is a separate target, but it's most convenient to
+					 * regard the entirety of a JOIN_ORDER specification as a
+					 * single target. Since it wasn't represented that way
+					 * during parsing, build a surrogate object now.
+					 */
+					target = palloc0_object(pgpa_advice_target);
+					target->ttype = PGPA_TARGET_ORDERED_LIST;
+					target->children = item->targets;
+
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_INDEX_ONLY_SCAN:
+			case PGPA_TAG_INDEX_SCAN:
+			case PGPA_TAG_SEQ_SCAN:
+			case PGPA_TAG_TID_SCAN:
+
+				/*
+				 * Scan advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					/*
+					 * For now, all of our scan types target single relations,
+					 * but in the future this might not be true, e.g. a custom
+					 * scan could replace a join.
+					 */
+					Assert(target->ttype == PGPA_TARGET_IDENTIFIER);
+					pgpa_trove_add_to_slice(&trove->scan,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_FOREIGN_JOIN:
+			case PGPA_TAG_HASH_JOIN:
+			case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			case PGPA_TAG_MERGE_JOIN_PLAIN:
+			case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			case PGPA_TAG_NESTED_LOOP_PLAIN:
+			case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			case PGPA_TAG_SEMIJOIN_UNIQUE:
+
+				/*
+				 * Join strategy advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_PARTITIONWISE:
+			case PGPA_TAG_GATHER:
+			case PGPA_TAG_GATHER_MERGE:
+			case PGPA_TAG_NO_GATHER:
+
+				/*
+				 * Advice about a RelOptInfo relevant to both scans and joins.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->rel,
+											item->tag, target);
+				}
+				break;
+		}
+	}
+
+	return trove;
+}
+
+/*
+ * Search a trove of advice for relevant entries.
+ *
+ * All parameters are input parameters except for *result, which is an output
+ * parameter used to return results to the caller.
+ */
+void
+pgpa_trove_lookup(pgpa_trove *trove, pgpa_trove_lookup_type type,
+				  int nrids, pgpa_identifier *rids, pgpa_trove_result *result)
+{
+	pgpa_trove_slice *tslice;
+	Bitmapset  *indexes;
+
+	Assert(nrids > 0);
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	indexes = pgpa_trove_slice_lookup(tslice, &rids[0]);
+	for (int i = 1; i < nrids; ++i)
+	{
+		Bitmapset  *other_indexes;
+
+		/*
+		 * If the caller is asking about two relations that aren't part of the
+		 * same subquery, they've messed up.
+		 */
+		Assert(strings_equal_or_both_null(rids[0].plan_name,
+										  rids[i].plan_name));
+
+		other_indexes = pgpa_trove_slice_lookup(tslice, &rids[i]);
+		indexes = bms_union(indexes, other_indexes);
+	}
+
+	result->entries = tslice->entries;
+	result->indexes = indexes;
+}
+
+/*
+ * Return all entries in a trove slice to the caller.
+ *
+ * The first two arguments are input arguments, and the remainder are output
+ * arguments.
+ */
+void
+pgpa_trove_lookup_all(pgpa_trove *trove, pgpa_trove_lookup_type type,
+					  pgpa_trove_entry **entries, int *nentries)
+{
+	pgpa_trove_slice *tslice;
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	*entries = tslice->entries;
+	*nentries = tslice->nused;
+}
+
+/*
+ * Convert a trove entry to an item of plan advice that would produce it.
+ */
+char *
+pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	appendStringInfo(&buf, "%s", pgpa_cstring_advice_tag(entry->tag));
+
+	/* JOIN_ORDER tags are transformed by pgpa_build_trove; undo that here */
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, '(');
+	else
+		Assert(entry->target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	pgpa_format_advice_target(&buf, entry->target);
+
+	if (entry->target->itarget != NULL)
+	{
+		appendStringInfoChar(&buf, ' ');
+		pgpa_format_index_target(&buf, entry->target->itarget);
+	}
+
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, ')');
+
+	return buf.data;
+}
+
+/*
+ * Set PGPA_TE_* flags on a set of trove entries.
+ */
+void
+pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
+{
+	int			i = -1;
+
+	while ((i = bms_next_member(indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+
+		entry->flags |= flags;
+	}
+}
+
+/*
+ * Add a new advice target to an existing pgpa_trove_slice object.
+ */
+static void
+pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+						pgpa_advice_tag_type tag,
+						pgpa_advice_target *target)
+{
+	pgpa_trove_entry *entry;
+
+	if (tslice->nused >= tslice->nallocated)
+	{
+		int			new_allocated;
+
+		new_allocated = tslice->nallocated * 2;
+		tslice->entries = repalloc_array(tslice->entries, pgpa_trove_entry,
+										 new_allocated);
+		tslice->nallocated = new_allocated;
+	}
+
+	entry = &tslice->entries[tslice->nused];
+	entry->tag = tag;
+	entry->target = target;
+	entry->flags = 0;
+
+	pgpa_trove_add_to_hash(tslice->hash, target, tslice->nused);
+
+	tslice->nused++;
+}
+
+/*
+ * Update the hash table for a newly-added advice target.
+ */
+static void
+pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash, pgpa_advice_target *target,
+					   int index)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	bool		found;
+
+	/* For non-identifiers, add entries for all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_trove_add_to_hash(hash, child_target, index);
+		}
+		return;
+	}
+
+	/* Sanity checks. */
+	Assert(target->rid.occurrence > 0);
+	Assert(target->rid.alias_name != NULL);
+
+	/* Add an entry for this relation identifier. */
+	key.alias_name = target->rid.alias_name;
+	key.partition_name = target->rid.partrel;
+	key.plan_name = target->rid.plan_name;
+	element = pgpa_trove_entry_insert(hash, key, &found);
+	element->indexes = bms_add_member(element->indexes, index);
+}
+
+/*
+ * Create and initialize a new pgpa_trove_slice object.
+ */
+static void
+pgpa_init_trove_slice(pgpa_trove_slice *tslice)
+{
+	/*
+	 * In an ideal world, we'll make tslice->nallocated big enough that the
+	 * array and hash table will be large enough to contain the number of
+	 * advice items in this trove slice, but a generous default value is not
+	 * good for performance, because pgpa_init_trove_slice() has to zero an
+	 * amount of memory proportional to tslice->nallocated. Hence, we keep the
+	 * starting value quite small, on the theory that advice strings will
+	 * often be relatively short.
+	 */
+	tslice->nallocated = 16;
+	tslice->nused = 0;
+	tslice->entries = palloc_array(pgpa_trove_entry, tslice->nallocated);
+	tslice->hash = pgpa_trove_entry_create(CurrentMemoryContext,
+										   tslice->nallocated, NULL);
+}
+
+/*
+ * Fast hash function for a key consisting of alias_name, partition_name,
+ * and plan_name.
+ */
+static uint32
+pgpa_trove_entry_hash_key(pgpa_trove_entry_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	/* alias_name may not be NULL */
+	sp_len = fasthash_accum_cstring(&hs, key.alias_name);
+
+	/* partition_name and plan_name, however, can be NULL */
+	if (key.partition_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.partition_name);
+	if (key.plan_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.plan_name);
+
+	/*
+	 * hashfn_unstable.h recommends using string length as tweak. It's not
+	 * clear to me what to do if there are multiple strings, so for now I'm
+	 * just using the total of all of the lengths.
+	 */
+	return fasthash_final32(&hs, sp_len);
+}
+
+/*
+ * Look for matching entries.
+ */
+static Bitmapset *
+pgpa_trove_slice_lookup(pgpa_trove_slice *tslice, pgpa_identifier *rid)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	Bitmapset  *result = NULL;
+
+	Assert(rid->occurrence >= 1);
+
+	key.alias_name = rid->alias_name;
+	key.partition_name = rid->partnsp;
+	key.plan_name = rid->plan_name;
+
+	element = pgpa_trove_entry_lookup(tslice->hash, key);
+
+	if (element != NULL)
+	{
+		int			i = -1;
+
+		while ((i = bms_next_member(element->indexes, i)) >= 0)
+		{
+			pgpa_trove_entry *entry = &tslice->entries[i];
+
+			/*
+			 * We know that this target or one of its descendents matches the
+			 * identifier on the three key fields above, but we don't know
+			 * which descendent or whether the occurence and schema also
+			 * match.
+			 */
+			if (pgpa_identifier_matches_target(rid, entry->target))
+				result = bms_add_member(result, i);
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
new file mode 100644
index 00000000000..479c3f75778
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -0,0 +1,113 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.h
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_TROVE_H
+#define PGPA_TROVE_H
+
+#include "pgpa_ast.h"
+
+#include "nodes/bitmapset.h"
+
+typedef struct pgpa_trove pgpa_trove;
+
+/*
+ * Flags that can be set on a pgpa_trove_entry to indicate what happened when
+ * trying to plan using advice.
+ *
+ * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_TE_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_TE_MATCH_PARTIAL		0x0001
+#define PGPA_TE_MATCH_FULL			0x0002
+#define PGPA_TE_INAPPLICABLE		0x0004
+#define PGPA_TE_CONFLICTING			0x0008
+#define PGPA_TE_FAILED				0x0010
+
+/*
+ * Each entry in a trove of advice represents the application of a tag to
+ * a single target.
+ */
+typedef struct pgpa_trove_entry
+{
+	pgpa_advice_tag_type tag;
+	pgpa_advice_target *target;
+	int			flags;
+} pgpa_trove_entry;
+
+/*
+ * What kind of information does the caller want to find in a trove?
+ *
+ * PGPA_TROVE_LOOKUP_SCAN means we're looking for scan advice.
+ *
+ * PGPA_TROVE_LOOKUP_JOIN means we're looking for join-related advice.
+ * This includes join order advice, join method advice, and semijoin-uniqueness
+ * advice.
+ *
+ * PGPA_TROVE_LOOKUP_REL means we're looking for general advice about this
+ * a RelOptInfo that may correspond to either a scan or a join. This includes
+ * gather-related advice and partitionwise advice. Note that partitionwise
+ * advice might seem like join advice, but that's not a helpful way of viewing
+ * the matter because (1) partitionwise advice is also relevant at the scan
+ * level and (2) other types of join advice affect only what to do from
+ * join_path_setup_hook, but partitionwise advice affects what to do in
+ * joinrel_setup_hook.
+ */
+typedef enum pgpa_trove_lookup_type
+{
+	PGPA_TROVE_LOOKUP_JOIN,
+	PGPA_TROVE_LOOKUP_REL,
+	PGPA_TROVE_LOOKUP_SCAN
+} pgpa_trove_lookup_type;
+
+/*
+ * This struct is used to store the result of a trove lookup. For each member
+ * of "indexes", the entry at the corresponding offset within "entries" is one
+ * of the results.
+ */
+typedef struct pgpa_trove_result
+{
+	pgpa_trove_entry *entries;
+	Bitmapset  *indexes;
+} pgpa_trove_result;
+
+extern pgpa_trove *pgpa_build_trove(List *advice_items);
+extern void pgpa_trove_lookup(pgpa_trove *trove,
+							  pgpa_trove_lookup_type type,
+							  int nrids,
+							  pgpa_identifier *rids,
+							  pgpa_trove_result *result);
+extern void pgpa_trove_lookup_all(pgpa_trove *trove,
+								  pgpa_trove_lookup_type type,
+								  pgpa_trove_entry **entries,
+								  int *nentries);
+extern char *pgpa_cstring_trove_entry(pgpa_trove_entry *entry);
+extern void pgpa_trove_set_flags(pgpa_trove_entry *entries,
+								 Bitmapset *indexes, int flags);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
new file mode 100644
index 00000000000..d22ac11bf91
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -0,0 +1,878 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.c
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/plannodes.h"
+
+static void pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+								  bool within_join_problem,
+								  pgpa_join_unroller *join_unroller,
+								  List *active_query_features,
+								  bool beneath_any_gather);
+static Bitmapset *pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+											 pgpa_unrolled_join *ujoin);
+
+static pgpa_query_feature *pgpa_add_feature(pgpa_plan_walker_context *walker,
+											pgpa_qf_type type,
+											Plan *plan);
+
+static void pgpa_qf_add_rti(List *active_query_features, Index rti);
+static void pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids);
+static void pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan);
+
+static bool pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+										   Index rtable_length,
+										   pgpa_identifier *rt_identifiers,
+										   pgpa_advice_target *target,
+										   bool toplevel);
+static bool pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+												  Index rtable_length,
+												  pgpa_identifier *rt_identifiers,
+												  pgpa_advice_target *target);
+static bool pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+									  pgpa_scan_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+										 pgpa_qf_type type,
+										 Bitmapset *relids);
+static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+									  pgpa_join_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+										   Bitmapset *relids);
+static Index pgpa_walker_get_rti(Index rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid);
+
+/*
+ * Top-level entrypoint for the plan tree walk.
+ *
+ * Populates walker based on a traversal of the Plan trees in pstmt.
+ */
+void
+pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt)
+{
+	ListCell   *lc;
+
+	/* Initialization. */
+	memset(walker, 0, sizeof(pgpa_plan_walker_context));
+	walker->pstmt = pstmt;
+
+	/* Walk the main plan tree. */
+	pgpa_walk_recursively(walker, pstmt->planTree, 0, NULL, NIL, false);
+
+	/* Main plan tree walk won't reach subplans, so walk those. */
+	foreach(lc, pstmt->subplans)
+	{
+		Plan	   *plan = lfirst(lc);
+
+		if (plan != NULL)
+			pgpa_walk_recursively(walker, plan, 0, NULL, NIL, false);
+	}
+}
+
+/*
+ * Main workhorse for the plan tree walk.
+ *
+ * If within_join_problem is true, we encountered a join at some higher level
+ * of the tree walk and haven't yet descended out of the portion of the plan
+ * tree that is part of that same join problem. We're no longer in the same
+ * join problem if (1) we cross into a different subquery or (2) we descend
+ * through an Append or MergeAppend node, below which any further joins would
+ * be partitionwise joins planned separately from the outer join problem.
+ *
+ * If join_unroller != NULL, the join unroller code expects us to find a join
+ * that should be unrolled into that object. This implies that we're within a
+ * join problem, but the reverse is not true: when we've traversed all the
+ * joins but are still looking for the scan that is the leaf of the join tree,
+ * join_unroller will be NULL but within_join_problem will be true.
+ *
+ * Each element of active_query_features corresponds to some item of advice
+ * that needs to enumerate all the relations it affects. We add RTIs we find
+ * during tree traversal to each of these query features.
+ *
+ * If beneath_any_gather == true, some higher level of the tree traversal found
+ * a Gather or Gather Merge node.
+ */
+static void
+pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+					  bool within_join_problem,
+					  pgpa_join_unroller *join_unroller,
+					  List *active_query_features,
+					  bool beneath_any_gather)
+{
+	pgpa_join_unroller *outer_join_unroller = NULL;
+	pgpa_join_unroller *inner_join_unroller = NULL;
+	bool		join_unroller_toplevel = false;
+	List	   *pushdown_query_features = NIL;
+	ListCell   *lc;
+	List	   *extraplans = NIL;
+	List	   *elided_nodes = NIL;
+	bool		is_query_feature = false;
+
+	Assert(within_join_problem || join_unroller == NULL);
+
+	/*
+	 * If this is a Gather or Gather Merge node, directly add it to the list
+	 * of currently-active query features.
+	 *
+	 * Otherwise, check the future_query_features list to see whether this was
+	 * previously identified as a plan node that needs to be treated as a
+	 * query feature.
+	 */
+	if (IsA(plan, Gather))
+	{
+		active_query_features =
+			lappend(active_query_features,
+					pgpa_add_feature(walker, PGPAQF_GATHER, plan));
+		is_query_feature = true;
+		beneath_any_gather = true;
+	}
+	else if (IsA(plan, GatherMerge))
+	{
+		active_query_features =
+			lappend(active_query_features,
+					pgpa_add_feature(walker, PGPAQF_GATHER_MERGE, plan));
+		is_query_feature = true;
+		beneath_any_gather = true;
+	}
+	else
+	{
+		foreach_ptr(pgpa_query_feature, qf, walker->future_query_features)
+		{
+			if (qf->plan == plan)
+			{
+				is_query_feature = true;
+				active_query_features = lappend(active_query_features, qf);
+				walker->future_query_features =
+					list_delete_ptr(walker->future_query_features, plan);
+				break;
+			}
+		}
+	}
+
+	/*
+	 * Find all elided nodes for this Plan node.
+	 */
+	foreach_node(ElidedNode, n, walker->pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_nodes = lappend(elided_nodes, n);
+	}
+
+	/* If we found any elided_nodes, handle them. */
+	if (elided_nodes != NIL)
+	{
+		int			num_elided_nodes = list_length(elided_nodes);
+		ElidedNode *last_elided_node;
+
+		/*
+		 * RTIs for the final -- and thus logically uppermost -- elided node
+		 * should be collected for query features passed down by the caller.
+		 * However, elided nodes act as barriers to query features, which
+		 * means that (1) the remaining elided nodes, if any, should be
+		 * ignored for purposes of query features and (2) the list of active
+		 * query features should be reset to empty so that we do not add RTIs
+		 * from the plan node that is logically beneath the elided node to the
+		 * query features passed down from the caller.
+		 */
+		last_elided_node = list_nth(elided_nodes, num_elided_nodes - 1);
+		pgpa_qf_add_rtis(active_query_features, last_elided_node->relids);
+		active_query_features = NIL;
+
+		/*
+		 * If we're within a join problem, the join_unroller is responsible
+		 * for building the scan for the final elided node, so throw it out.
+		 */
+		if (within_join_problem)
+			elided_nodes = list_truncate(elided_nodes, num_elided_nodes - 1);
+
+		/* Build scans for all (or the remaining) elided nodes. */
+		foreach_node(ElidedNode, elided_node, elided_nodes)
+		{
+			(void) pgpa_build_scan(walker, plan, elided_node,
+								   beneath_any_gather, within_join_problem);
+		}
+
+		/*
+		 * If there were any elided nodes, then everything beneath those nodes
+		 * is not part of the same join problem.
+		 *
+		 * In more detail, if an Append or MergeAppend was elided, then a
+		 * partitionwise join was chosen and only a single child survived; if
+		 * a SubqueryScan was elided, the subquery was planned without
+		 * flattening it into the parent.
+		 */
+		within_join_problem = false;
+		join_unroller = NULL;
+	}
+
+	/*
+	 * If we're within a join problem, the join unroller is responsible for
+	 * building any required scan for this node. If not, we do it here.
+	 */
+	if (!within_join_problem)
+		(void) pgpa_build_scan(walker, plan, NULL, beneath_any_gather, false);
+
+	/*
+	 * If this join needs to unrolled but there's no join unroller already
+	 * available, create one.
+	 */
+	if (join_unroller == NULL && pgpa_is_join(plan))
+	{
+		join_unroller = pgpa_create_join_unroller();
+		join_unroller_toplevel = true;
+		within_join_problem = true;
+	}
+
+	/*
+	 * If this join is to be unrolled, pgpa_unroll_join() will return the join
+	 * unroller object that should be passed down when we recurse into the
+	 * outer and inner sides of the plan.
+	 */
+	if (join_unroller != NULL)
+		pgpa_unroll_join(walker, plan, beneath_any_gather, join_unroller,
+						 &outer_join_unroller, &inner_join_unroller);
+
+	/* Add RTIs from the plan node to all active query features. */
+	pgpa_qf_add_plan_rtis(active_query_features, plan);
+
+	/*
+	 * Recurse into the outer and inner subtrees.
+	 *
+	 * As an exception, if this is a ForeignScan, don't recurse. postgres_fdw
+	 * sometimes stores an EPQ recheck plan in plan->leftree, but that's going
+	 * to mention the same set of relations as the ForeignScan itself, and we
+	 * have no way to emit advice targeting the EPQ case vs. the non-EPQ case.
+	 * Moreover, it's not entirely clear what other FDWs might do with the
+	 * left and right subtrees. Maybe some better handling is needed here, but
+	 * for now, we just punt.
+	 */
+	if (!IsA(plan, ForeignScan))
+	{
+		if (plan->lefttree != NULL)
+			pgpa_walk_recursively(walker, plan->lefttree, within_join_problem,
+								  outer_join_unroller, active_query_features,
+								  beneath_any_gather);
+		if (plan->righttree != NULL)
+			pgpa_walk_recursively(walker, plan->righttree, within_join_problem,
+								  inner_join_unroller, active_query_features,
+								  beneath_any_gather);
+	}
+
+	/*
+	 * If we created a join unroller up above, then it's also our join to use
+	 * it to build the final pgpa_unrolled_join, and to destroy the object.
+	 */
+	if (join_unroller_toplevel)
+	{
+		pgpa_unrolled_join *ujoin;
+
+		ujoin = pgpa_build_unrolled_join(walker, join_unroller);
+		walker->toplevel_unrolled_joins =
+			lappend(walker->toplevel_unrolled_joins, ujoin);
+		pgpa_destroy_join_unroller(join_unroller);
+		(void) pgpa_process_unrolled_join(walker, ujoin);
+	}
+
+	/*
+	 * Some plan types can have additional children. Nodes like Append that
+	 * can have any number of children store them in a List; a SubqueryScan
+	 * just has a field for a single additional Plan.
+	 */
+	switch (nodeTag(plan))
+	{
+		case T_Append:
+			{
+				Append	   *aplan = (Append *) plan;
+
+				extraplans = aplan->appendplans;
+				if (bms_is_empty(aplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_MergeAppend:
+			{
+				MergeAppend *maplan = (MergeAppend *) plan;
+
+				extraplans = maplan->mergeplans;
+				if (bms_is_empty(maplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_BitmapAnd:
+			extraplans = ((BitmapAnd *) plan)->bitmapplans;
+			break;
+		case T_BitmapOr:
+			extraplans = ((BitmapOr *) plan)->bitmapplans;
+			break;
+		case T_SubqueryScan:
+
+			/*
+			 * We don't pass down active_query_features across here, because
+			 * those are specific to a subquery level.
+			 */
+			pgpa_walk_recursively(walker, ((SubqueryScan *) plan)->subplan,
+								  0, NULL, NIL, beneath_any_gather);
+			break;
+		case T_CustomScan:
+			extraplans = ((CustomScan *) plan)->custom_plans;
+			break;
+		default:
+			break;
+	}
+
+	/* If we found a list of extra children, iterate over it. */
+	foreach(lc, extraplans)
+	{
+		Plan	   *subplan = lfirst(lc);
+
+		pgpa_walk_recursively(walker, subplan, 0, NULL, pushdown_query_features,
+							  beneath_any_gather);
+	}
+
+	/*
+	 * If the current node is a query feature, then active_query_features has
+	 * been destructively modified as compared to the value passed down from
+	 * the caller, and we need to put things back as they were.
+	 *
+	 * Exceptions: If the caller passed NIL, or if we reset the list to NIL
+	 * because of the presence of an elided SubqueryScan, then we created a
+	 * new list above, rather than destructively modifying the caller's list.
+	 * That case requires no special handling, because list_truncate() will
+	 * simply exit quickly.
+	 */
+	if (is_query_feature)
+	{
+		int			num_aqf = list_length(active_query_features);
+
+		(void) list_truncate(active_query_features, num_aqf - 1);
+	}
+}
+
+/*
+ * Perform final processing of a newly-constructed pgpa_unrolled_join. This
+ * only needs to be called for toplevel pgpa_unrolled_join objects, since it
+ * recurses to sub-joins as needed.
+ *
+ * Our goal is to add the set of inner relids to the relevant join_strategies
+ * list, and to do the same for any sub-joins. To that end, the return value
+ * is the set of relids found beneath the inner side of the join, but it is
+ * expected that the toplevel caller will ignore this.
+ */
+static Bitmapset *
+pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+						   pgpa_unrolled_join *ujoin)
+{
+	Bitmapset  *all_relids = NULL;
+
+	for (int k = 0; k < ujoin->ninner; ++k)
+	{
+		pgpa_join_member *member = &ujoin->inner[k];
+		Bitmapset  *relids;
+
+		if (member->unrolled_join != NULL)
+			relids = pgpa_process_unrolled_join(walker,
+												member->unrolled_join);
+		else
+		{
+			Assert(member->scan != NULL);
+			relids = member->scan->relids;
+		}
+		walker->join_strategies[ujoin->strategy[k]] =
+			lappend(walker->join_strategies[ujoin->strategy[k]], relids);
+		all_relids = bms_add_members(all_relids, relids);
+	}
+
+	return all_relids;
+}
+
+/*
+ * Arrange for the given plan node to be treated as a query feature when the
+ * tree walk reaches it.
+ *
+ * Make sure to only use this for nodes that the tree walk can't have reached
+ * yet!
+ */
+void
+pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+						pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = pgpa_add_feature(walker, type, plan);
+
+	walker->future_query_features =
+		lappend(walker->future_query_features, qf);
+}
+
+/*
+ * Return the last of any elided nodes associated with this plan node ID.
+ *
+ * The last elided node is the one that would have been uppermost in the plan
+ * tree had it not been removed during setrefs processig.
+ */
+ElidedNode *
+pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan)
+{
+	ElidedNode *elided_node = NULL;
+
+	foreach_node(ElidedNode, n, pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_node = n;
+	}
+
+	return elided_node;
+}
+
+/*
+ * Certain plan nodes can refer to a set of RTIs. Extract and return the set.
+ */
+Bitmapset *
+pgpa_relids(Plan *plan)
+{
+	if (IsA(plan, Result))
+		return ((Result *) plan)->relids;
+	else if (IsA(plan, ForeignScan))
+		return ((ForeignScan *) plan)->fs_relids;
+	else if (IsA(plan, Append))
+		return ((Append *) plan)->apprelids;
+	else if (IsA(plan, MergeAppend))
+		return ((MergeAppend *) plan)->apprelids;
+
+	return NULL;
+}
+
+/*
+ * Extract the scanned RTI from a plan node.
+ *
+ * Returns 0 if there isn't one.
+ */
+Index
+pgpa_scanrelid(Plan *plan)
+{
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+		case T_SubqueryScan:
+		case T_FunctionScan:
+		case T_TableFuncScan:
+		case T_ValuesScan:
+		case T_CteScan:
+		case T_NamedTuplestoreScan:
+		case T_WorkTableScan:
+		case T_ForeignScan:
+		case T_CustomScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+			return ((Scan *) plan)->scanrelid;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Create a pgpa_query_feature and add it to the list of all query features
+ * for this plan.
+ */
+static pgpa_query_feature *
+pgpa_add_feature(pgpa_plan_walker_context *walker,
+				 pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = palloc0_object(pgpa_query_feature);
+
+	qf->type = type;
+	qf->plan = plan;
+
+	walker->query_features[qf->type] =
+		lappend(walker->query_features[qf->type], qf);
+
+	return qf;
+}
+
+/*
+ * Add a single RTI to each active query feature.
+ */
+static void
+pgpa_qf_add_rti(List *active_query_features, Index rti)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_member(qf->relids, rti);
+	}
+}
+
+/*
+ * Add a set of RTIs to each active query feature.
+ */
+static void
+pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_members(qf->relids, relids);
+	}
+}
+
+/*
+ * Add RTIs directly contained in a plan node to each active query feature.
+ */
+static void
+pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan)
+{
+	Bitmapset  *relids;
+	Index		rti;
+
+	if ((relids = pgpa_relids(plan)) != NULL)
+		pgpa_qf_add_rtis(active_query_features, relids);
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+		pgpa_qf_add_rti(active_query_features, rti);
+}
+
+/*
+ * If we generated plan advice using the provided walker object and array
+ * of identifiers, would we generate the specified tag/target combination?
+ *
+ * If yes, the plan conforms to the advice; if no, it does not. Note that
+ * we have know way of knowing whether the planner was forced to emit a plan
+ * that conformed to the advice or just happened to do so.
+ */
+bool
+pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+						 pgpa_identifier *rt_identifiers,
+						 pgpa_advice_tag_type tag,
+						 pgpa_advice_target *target)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	Bitmapset  *relids = NULL;
+
+	if (tag == PGPA_TAG_JOIN_ORDER)
+	{
+		foreach_ptr(pgpa_unrolled_join, ujoin, walker->toplevel_unrolled_joins)
+		{
+			if (pgpa_walker_join_order_matches(ujoin, rtable_length,
+											   rt_identifiers, target, true))
+				return true;
+		}
+
+		return false;
+	}
+
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		Index		rti;
+
+		rti = pgpa_walker_get_rti(rtable_length, rt_identifiers, &target->rid);
+		relids = bms_make_singleton(rti);
+	}
+	else
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			Index		rti;
+
+			Assert(child_target->ttype == PGPA_TARGET_IDENTIFIER);
+			rti = pgpa_compute_rti_from_identifier(rtable_length,
+												   rt_identifiers,
+												   &child_target->rid);
+			if (rti == 0)
+				elog(ERROR, "cannot determine RTI for advice target");
+			relids = bms_add_member(relids, rti);
+		}
+	}
+
+	switch (tag)
+	{
+		case PGPA_TAG_JOIN_ORDER:
+			/* should have been handled above */
+			Assert(false);
+			break;
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_BITMAP_HEAP,
+											 relids);
+		case PGPA_TAG_FOREIGN_JOIN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_FOREIGN,
+											 relids);
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX_ONLY,
+											 relids);
+		case PGPA_TAG_INDEX_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX,
+											 relids);
+		case PGPA_TAG_PARTITIONWISE:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_PARTITIONWISE,
+											 relids);
+		case PGPA_TAG_SEQ_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_SEQ,
+											 relids);
+		case PGPA_TAG_TID_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_TID,
+											 relids);
+		case PGPA_TAG_GATHER:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER,
+												relids);
+		case PGPA_TAG_GATHER_MERGE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER_MERGE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_NON_UNIQUE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_UNIQUE,
+												relids);
+		case PGPA_TAG_HASH_JOIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_HASH_JOIN,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_PLAIN,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MEMOIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_PLAIN,
+											 relids);
+		case PGPA_TAG_NO_GATHER:
+			return pgpa_walker_contains_no_gather(walker, relids);
+	}
+
+	/* should not get here */
+	return false;
+}
+
+/*
+ * Does an unrolled join match the join order specified by an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+							   Index rtable_length,
+							   pgpa_identifier *rt_identifiers,
+							   pgpa_advice_target *target,
+							   bool toplevel)
+{
+	int		nchildren = list_length(target->children);
+
+	Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	/* At toplevel, we allow a prefix match. */
+	if (toplevel)
+	{
+		if (nchildren > ujoin->ninner + 1)
+			return false;
+	}
+	else
+	{
+		if (nchildren != ujoin->ninner + 1)
+			return false;
+	}
+
+	/* Outermost rel must match. */
+	if (!pgpa_walker_join_order_matches_member(&ujoin->outer,
+											   rtable_length,
+											   rt_identifiers,
+											   linitial(target->children)))
+		return false;
+
+	/* Each inner rel must match. */
+	for (int n = 0; n < nchildren - 1; ++n)
+	{
+		pgpa_advice_target *child_target = list_nth(target->children, n + 1);
+
+		if (!pgpa_walker_join_order_matches_member(&ujoin->inner[n],
+												   rtable_length,
+												   rt_identifiers,
+												   child_target))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Does one member of an unrolled join match an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+									  Index rtable_length,
+									  pgpa_identifier *rt_identifiers,
+									  pgpa_advice_target *target)
+{
+	Bitmapset  *relids = NULL;
+
+	if (member->unrolled_join != NULL)
+	{
+		if (target->ttype != PGPA_TARGET_ORDERED_LIST)
+			return false;
+		return pgpa_walker_join_order_matches(member->unrolled_join,
+											  rtable_length,
+											  rt_identifiers,
+											  target,
+											  false);
+	}
+
+	Assert(member->scan != NULL);
+	switch (target->ttype)
+	{
+		case PGPA_TARGET_ORDERED_LIST:
+			/* Could only match an unrolled join */
+			return false;
+
+		case PGPA_TARGET_UNORDERED_LIST:
+			{
+				foreach_ptr(pgpa_advice_target, child_target, target->children)
+				{
+					Index		rti;
+
+					rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+											  &child_target->rid);
+					relids = bms_add_member(relids, rti);
+				}
+			}
+
+		case PGPA_TARGET_IDENTIFIER:
+			{
+				Index		rti;
+
+				rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+										  &target->rid);
+				relids = bms_make_singleton(rti);
+			}
+	}
+
+	return bms_equal(member->scan->relids, relids);
+}
+
+/*
+ * Does this walker say that the given scan strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+						  pgpa_scan_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *scans = walker->scans[strategy];
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		/*
+		 * XXX. If this is index-related advice, we should also validate that
+		 * the advice target's index target matches the Plan tree.
+		 */
+		if (bms_equal(scan->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does this walker say that the given query feature applies to the given
+ * relid set?
+ */
+static bool
+pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+							 pgpa_qf_type type,
+							 Bitmapset *relids)
+{
+	List	   *query_features = walker->query_features[type];
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (bms_equal(qf->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given join strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+						  pgpa_join_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *join_strategies = walker->join_strategies[strategy];
+
+	foreach_ptr(Bitmapset, jsrelids, join_strategies)
+	{
+		if (bms_equal(jsrelids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given relids should be marked as NO_GATHER?
+ */
+static bool
+pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+							   Bitmapset *relids)
+{
+	return bms_is_subset(relids, walker->no_gather_scans);
+}
+
+/*
+ * Convenience function to convert a relation identifier to an RTI.
+ *
+ * We throw an error here because we expect this to be used on system-generated
+ * advice. Hence, failure here indicates an advice generation bug.
+ */
+static Index
+pgpa_walker_get_rti(Index rtable_length,
+					pgpa_identifier *rt_identifiers,
+					pgpa_identifier *rid)
+{
+	Index		rti;
+
+	rti = pgpa_compute_rti_from_identifier(rtable_length,
+										   rt_identifiers,
+										   rid);
+	if (rti == 0)
+		elog(ERROR, "cannot determine RTI for advice target");
+	return rti;
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
new file mode 100644
index 00000000000..d6584c014b9
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -0,0 +1,121 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.h
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_WALKER_H
+#define PGPA_WALKER_H
+
+#include "pgpa_ast.h"
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+
+/*
+ * We use the term "query feature" to refer to plan nodes that are interesting
+ * in the following way: to generate advice, we'll need to know the set of
+ * same-subquery, non-join RTIs occuring at or below that plan node, without
+ * admixture of parent and child RTIs.
+ *
+ * For example, Gather nodes, desiginated by PGPAQF_GATHER, and Gather Merge
+ * nodes, designated by PGPAQF_GATHER_MERGE, are query features, because we'll
+ * want to admit some kind of advice that describes the portion of the plan
+ * tree that appears beneath those nodes.
+ *
+ * Each semijoin can be implemented either by directly performing a semijoin,
+ * or by making one side unique and then performing a normal join. Either way,
+ * we use a query feature to notice what decision was made, so that we can
+ * describe it by enumerating the RTIs on that side of the join.
+ *
+ * To elaborate on the "no admixture of parent and child RTIs" rule, in all of
+ * these cases, if the entirety of an inheritance hierarchy appears beneath
+ * the query feature, we only want to name the parent table. But it's also
+ * possible to have cases where we must name child tables. This is particularly
+ * likely to happen when partitionwise join is in use, but could happen for
+ * Gather or Gather Merge even without that, if one of those appears below
+ * an Append or MergeAppend node for a single table.
+ */
+typedef enum pgpa_qf_type
+{
+	PGPAQF_GATHER,
+	PGPAQF_GATHER_MERGE,
+	PGPAQF_SEMIJOIN_NON_UNIQUE,
+	PGPAQF_SEMIJOIN_UNIQUE
+	/* update NUM_PGPA_QF_TYPES if you add anything here */
+} pgpa_qf_type;
+
+#define NUM_PGPA_QF_TYPES ((int) PGPAQF_SEMIJOIN_UNIQUE + 1)
+
+/*
+ * For each query feature, we keep track of the feature type and the set of
+ * relids that we found underneath the relevant plan node. See the comments
+ * on pgpa_qf_type, above, for additional details.
+ */
+typedef struct pgpa_query_feature
+{
+	pgpa_qf_type type;
+	Plan	   *plan;
+	Bitmapset  *relids;
+} pgpa_query_feature;
+
+/*
+ * Context object for plan tree walk.
+ *
+ * pstmt is the PlannedStmt we're studying.
+ *
+ * scans is an array of lists of pgpa_scan objects. The array is indexed by
+ * the scan's pgpa_scan_strategy.
+ *
+ * no_gather_scans is the set of scan RTIs that do not appear beneath any
+ * Gather or Gather Merge node.
+ *
+ * toplevel_unrolled_joins is a list of all pgpa_unrolled_join objects that
+ * are not a child of some other pgpa_unrolled_join.
+ *
+ * join_strategy is an array of lists of Bitmapset objects. Each Bitmapset
+ * is the set of relids that appears on the inner side of some join (excluding
+ * RTIs from partition children and subqueries). The array is indexed by
+ * pgpa_join_strategy.
+ *
+ * query_features is an array lists of pgpa_query_feature objects, indexed
+ * by pgpa_qf_type.
+ *
+ * future_query_features is only used during the plan tree walk and should
+ * be empty when the tree walk concludes. It is a list of pgpa_query_feature
+ * objects for Plan nodes that the plan tree walk has not yet encountered;
+ * when encountered, they will be moved to the list of active query features
+ * that is propagated via the call stack.
+ */
+typedef struct pgpa_plan_walker_context
+{
+	PlannedStmt *pstmt;
+	List	   *scans[NUM_PGPA_SCAN_STRATEGY];
+	Bitmapset  *no_gather_scans;
+	List	   *toplevel_unrolled_joins;
+	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
+	List	   *query_features[NUM_PGPA_QF_TYPES];
+	List	   *future_query_features;
+} pgpa_plan_walker_context;
+
+extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
+							 PlannedStmt *pstmt);
+
+extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+									pgpa_qf_type type,
+									Plan *plan);
+
+extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
+extern Bitmapset *pgpa_relids(Plan *plan);
+extern Index pgpa_scanrelid(Plan *plan);
+
+extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+									 pgpa_identifier *rt_identifiers,
+									 pgpa_advice_tag_type tag,
+									 pgpa_advice_target *target);
+
+#endif
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
new file mode 100644
index 00000000000..6b15e18e98e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -0,0 +1,75 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_order.sql b/contrib/pg_plan_advice/sql/join_order.sql
new file mode 100644
index 00000000000..5aa2fc62d34
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_order.sql
@@ -0,0 +1,96 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
+
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/sql/join_strategy.sql b/contrib/pg_plan_advice/sql/join_strategy.sql
new file mode 100644
index 00000000000..8eb823f1c0e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_strategy.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/partitionwise.sql b/contrib/pg_plan_advice/sql/partitionwise.sql
new file mode 100644
index 00000000000..e42c0611760
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/partitionwise.sql
@@ -0,0 +1,78 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
new file mode 100644
index 00000000000..642205cc097
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -0,0 +1,132 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+COMMIT;
+
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+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 > 0;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+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;
+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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+COMMIT;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index f7f79fa01f4..cdc0f78df61 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3918,6 +3918,43 @@ pg_wc_probefunc
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgpa_collected_advice
+pgpa_advice_item
+pgpa_advice_tag_type
+pgpa_advice_target
+pgpa_identifier
+pgpa_index_target
+pgpa_index_type
+pgpa_itm_type
+pgpa_join_class
+pgpa_join_member
+pgpa_join_state
+pgpa_join_strategy
+pgpa_join_unroller
+pgpa_local_advice
+pgpa_local_advice_chunk
+pgpa_output_context
+pgpa_plan_walker_context
+pgpa_planner_state
+pgpa_qf_type
+pgpa_query_feature
+pgpa_ri_checker
+pgpa_ri_checker_key
+pgpa_scan
+pgpa_scan_strategy
+pgpa_shared_advice
+pgpa_shared_advice_chunk
+pgpa_shared_state
+pgpa_target_type
+pgpa_trove
+pgpa_trove_entry
+pgpa_trove_entry_element
+pgpa_trove_entry_hash
+pgpa_trove_entry_key
+pgpa_trove_lookup_type
+pgpa_trove_result
+pgpa_trove_slice
+pgpa_unrolled_join
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.39.3 (Apple Git-145)



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-10-31 09:58  Jakub Wartak <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 2 replies; 133+ messages in thread

From: Jakub Wartak @ 2025-10-31 09:58 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Thu, Oct 30, 2025 at 3:00 PM Robert Haas <[email protected]> wrote:

[..over 400kB of attachments, yay]

Thank You for working on this!

My gcc-13 was nitpicking a little bit (see
compilation_warnings_v1.txt), so attached is just a tiny diff to fix
some of those issues. After that, clang-20 run was clean too.

> First, any form of user control over the planner tends to be a lightning rod for criticism around here.

I do not know where this is coming from, but everybody I've talked to
was saying this is needed to handle real enterprise databases and
applications. I just really love it, how one could precisely adjust
the plan with this even with the presence of heavy aliasing:

postgres=# explain (plan_advice, costs off) SELECT * FROM (select *
from t1 a join t2 b using (id)) a, t2 b, t3 c WHERE a.id = b.id and
b.id = c.id;
                     QUERY PLAN
-----------------------------------------------------
 Merge Join
   Merge Cond: (a.id = c.id)
   ->  Merge Join
         Merge Cond: (a.id = b.id)
         ->  Index Scan using t1_pkey on t1 a
         ->  Index Scan using t2_pkey on t2 b
   ->  Sort
         Sort Key: c.id
         ->  Seq Scan on t3 c
 Supplied Plan Advice:
   SEQ_SCAN(ble5) /* not matched */
 Generated Plan Advice:
   JOIN_ORDER(a#2 b#2 c)
   MERGE_JOIN_PLAIN(b#2 c)
   SEQ_SCAN(c)
   INDEX_SCAN(a#2 public.t1_pkey )
   NO_GATHER(c a#2 b#2)
(17 rows)

postgres=# set pg_plan_advice.advice = 'SEQ_SCAN(b#2)';
SET
postgres=# explain (plan_advice, costs off) SELECT * FROM (select *
from t1 a join t2 b using (id)) a, t2 b, t3 c WHERE a.id = b.id and
b.id = c.id;
                     QUERY PLAN
----------------------------------------------------
 Hash Join
   Hash Cond: (b.id = a.id)
   ->  Seq Scan on t2 b
   ->  Hash
         ->  Merge Join
               Merge Cond: (a.id = c.id)
               ->  Index Scan using t1_pkey on t1 a
               ->  Sort
                     Sort Key: c.id
                     ->  Seq Scan on t3 c
 Supplied Plan Advice:
   SEQ_SCAN(b#2) /* matched */
 Generated Plan Advice:
   JOIN_ORDER(b#2 (a#2 c))
   MERGE_JOIN_PLAIN(c)
   HASH_JOIN(c)
   SEQ_SCAN(b#2 c)
   INDEX_SCAN(a#2 public.t1_pkey)
   NO_GATHER(c a#2 b#2)

To attract a little attention to the $thread, the only bigger design
(usability) question that keeps ringing in my head is how we are going
to bind it to specific queries without even issuing any SETs(or ALTER
USER) in the far future in the grand scheme of things. The discussed
query id (hash), full query text comparison, maybe even strstr(query ,
"partial hit") or regex all seem to be kind too limited in terms of
what crazy ORMs can come up with (each query will be potentially
slightly different, but if optimizer reference points are stable that
should nail it good enough, but just enabling it for the very specific
set of queries and not the others [with same aliases] is some major
challenge).

Due to this, at some point I was even thinking about some hashes for
every plan node (including hashes of subplans), e.g.:
 Merge Join // hash(MERGE_JOIN_PLAIN(b#2) + ';' somehashval1 + ';'+
somehahsval2 ) => somehashval3
   Merge Cond: (a.id = c.id)
   ->  Merge Join
         Merge Cond: (a.id = b.id)
         ->  Index Scan using t1_pkey on t1 a // hash(INDEX_SCAN(a#2
public.t1_pkey)) => somehashval1
         ->  Index Scan using t2_pkey on t2 b // hash(INDEX_SCAN(b#2
public.t2_pkey)) => somehashval2

and then having a way to use `somehashval3` (let's say it's SHA1) as a
way to activate the necessary advice. Something like having a way to
express it using plan_advice.on_subplanhashes_plan_advice =
'somehashval3: SEQ_SCAN(b#2)'. This would have the benefit of being
able to override multiple similiar SQL queries in one go rather than
collecting all possible query_ids, but probably it's stupid,
heavyweight, but that would be my dream ;)

-J.

From 06038e237420b7054912118076e8b981def4f545 Mon Sep 17 00:00:00 2001
From: Jakub Wartak <[email protected]>
Date: Fri, 31 Oct 2025 09:35:59 +0100
Subject: [PATCH] fixup: supress some gcc warnings

---
 contrib/pg_plan_advice/pgpa_ast.c    |  3 ++-
 contrib/pg_plan_advice/pgpa_output.c | 12 ++++++++----
 contrib/pg_plan_advice/pgpa_walker.c |  4 +++-
 3 files changed, 13 insertions(+), 6 deletions(-)

diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
index ed18950af18..be598874c48 100644
--- a/contrib/pg_plan_advice/pgpa_ast.c
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -138,7 +138,8 @@ pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
 			return "TID_SCAN";
 	}
 
-	Assert(false);
+	elog(ERROR, "unrecognized advice type: %d", advice_tag);
+	pg_unreachable();
 }
 
 /*
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
index 2175278b580..5aae5071990 100644
--- a/contrib/pg_plan_advice/pgpa_output.c
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -10,6 +10,7 @@
  *-------------------------------------------------------------------------
  */
 
+#include "c.h"
 #include "postgres.h"
 
 #include "pgpa_output.h"
@@ -507,7 +508,8 @@ pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
 			return "HASH_JOIN";
 	}
 
-	Assert(false);
+	elog(ERROR, "unrecognized join strategy: %d", strategy);
+	pg_unreachable();
 }
 
 /*
@@ -536,11 +538,12 @@ pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
 			return "TID_SCAN";
 	}
 
-	Assert(false);
+	elog(ERROR, "unrecognized scan strategy: %d", strategy);
+	pg_unreachable();
 }
 
 /*
- * Get a C string that corresponds to the specified scan strategy.
+ * Get a C string that corresponds to the specified query feature type.
  */
 static char *
 pgpa_cstring_query_feature_type(pgpa_qf_type type)
@@ -557,7 +560,8 @@ pgpa_cstring_query_feature_type(pgpa_qf_type type)
 			return "SEMIJOIN_UNIQUE";
 	}
 
-	Assert(false);
+	elog(ERROR, "unrecognized query feature type: %d", type);
+	pg_unreachable();
 }
 
 /*
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
index d22ac11bf91..44adeb4511b 100644
--- a/contrib/pg_plan_advice/pgpa_walker.c
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -359,7 +359,7 @@ pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
 	{
 		int			num_aqf = list_length(active_query_features);
 
-		(void) list_truncate(active_query_features, num_aqf - 1);
+		active_query_features = list_truncate(active_query_features, num_aqf - 1);
 	}
 }
 
@@ -768,6 +768,7 @@ pgpa_walker_join_order_matches_member(pgpa_join_member *member,
 					relids = bms_add_member(relids, rti);
 				}
 			}
+			break;
 
 		case PGPA_TARGET_IDENTIFIER:
 			{
@@ -777,6 +778,7 @@ pgpa_walker_join_order_matches_member(pgpa_join_member *member,
 										  &target->rid);
 				relids = bms_make_singleton(rti);
 			}
+			break;
 	}
 
 	return bms_equal(member->scan->relids, relids);
-- 
2.43.0


From 4fdb5d6b047fc7fd188be23c64ae7f352b014f21 Mon Sep 17 00:00:00 2001
From: Jakub Wartak <[email protected]>
Date: Fri, 31 Oct 2025 09:34:58 +0100
Subject: [PATCH 1/2] fixup: not sure

---
 contrib/pg_plan_advice/pgpa_planner.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
index c4859c23020..5b7d2cbd9f4 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -1411,7 +1411,7 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
 		 * equivalent to allowing the non-use of either form of Gather here.
 		 */
-		if (my_entry->tag == PGPA_TAG_GATHER |
+		if (my_entry->tag == PGPA_TAG_GATHER ||
 			my_entry->tag == PGPA_TAG_GATHER_MERGE)
 		{
 			if (!just_one_rel)
-- 
2.43.0


../contrib/pg_plan_advice/pgpa_ast.c: In function ‘pgpa_cstring_advice_tag’:
../contrib/pg_plan_advice/pgpa_ast.c:142:1: warning: control reaches end of non-void function [-Wreturn-type]
  142 | }
      | ^
../contrib/pg_plan_advice/pgpa_output.c: In function ‘pgpa_cstring_join_strategy’:
../contrib/pg_plan_advice/pgpa_output.c:511:1: warning: control reaches end of non-void function [-Wreturn-type]
  511 | }
      | ^
../contrib/pg_plan_advice/pgpa_output.c: In function ‘pgpa_cstring_scan_strategy’:
../contrib/pg_plan_advice/pgpa_output.c:540:1: warning: control reaches end of non-void function [-Wreturn-type]
  540 | }
      | ^
../contrib/pg_plan_advice/pgpa_output.c: In function ‘pgpa_cstring_query_feature_type’:
../contrib/pg_plan_advice/pgpa_output.c:561:1: warning: control reaches end of non-void function [-Wreturn-type]
  561 | }
      | ^
../contrib/pg_plan_advice/pgpa_walker.c: In function ‘pgpa_walk_recursively’:
../contrib/pg_plan_advice/pgpa_walker.c:362:24: warning: ignoring return value of ‘list_truncate’ declared with attribute ‘warn_unused_result’ [-Wunused-result]
  362 |                 (void) list_truncate(active_query_features, num_aqf - 1);
      |                        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In file included from ../src/include/nodes/primnodes.h:23,
                 from ../src/include/nodes/plannodes.h:23,
                 from ../contrib/pg_plan_advice/pgpa_join.h:15,
                 from ../contrib/pg_plan_advice/pgpa_walker.c:14:
../contrib/pg_plan_advice/pgpa_walker.c: In function ‘pgpa_walker_join_order_matches_member’:
../src/include/nodes/pg_list.h:482:9: warning: this statement may fall through [-Wimplicit-fallthrough=]
  482 |         for (type pointer var = 0, pointer var##__outerloop = (type pointer) 1; \
      |         ^~~
../src/include/nodes/pg_list.h:469:37: note: in expansion of macro ‘foreach_internal’
  469 | #define foreach_ptr(type, var, lst) foreach_internal(type, *, var, lst, lfirst)
      |                                     ^~~~~~~~~~~~~~~~
../contrib/pg_plan_advice/pgpa_walker.c:762:33: note: in expansion of macro ‘foreach_ptr’
  762 |                                 foreach_ptr(pgpa_advice_target, child_target, target->children)
      |                                 ^~~~~~~~~~~
../contrib/pg_plan_advice/pgpa_walker.c:772:17: note: here
  772 |                 case PGPA_TARGET_IDENTIFIER:
      |                 ^~~~
../contrib/pg_plan_advice/pgpa_planner.c: In function ‘pgpa_planner_apply_scan_advice’:
../contrib/pg_plan_advice/pgpa_planner.c:1414:35: warning: suggest parentheses around comparison in operand of ‘|’ [-Wparentheses]
 1414 |                 if (my_entry->tag == PGPA_TAG_GATHER |
      |                     ~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~


Attachments:

  [text/plain] 0001-fixup-supress-some-gcc-warnings.txt (2.9K, 2-0001-fixup-supress-some-gcc-warnings.txt)
  download | inline diff:
From 06038e237420b7054912118076e8b981def4f545 Mon Sep 17 00:00:00 2001
From: Jakub Wartak <[email protected]>
Date: Fri, 31 Oct 2025 09:35:59 +0100
Subject: [PATCH] fixup: supress some gcc warnings

---
 contrib/pg_plan_advice/pgpa_ast.c    |  3 ++-
 contrib/pg_plan_advice/pgpa_output.c | 12 ++++++++----
 contrib/pg_plan_advice/pgpa_walker.c |  4 +++-
 3 files changed, 13 insertions(+), 6 deletions(-)

diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
index ed18950af18..be598874c48 100644
--- a/contrib/pg_plan_advice/pgpa_ast.c
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -138,7 +138,8 @@ pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
 			return "TID_SCAN";
 	}
 
-	Assert(false);
+	elog(ERROR, "unrecognized advice type: %d", advice_tag);
+	pg_unreachable();
 }
 
 /*
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
index 2175278b580..5aae5071990 100644
--- a/contrib/pg_plan_advice/pgpa_output.c
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -10,6 +10,7 @@
  *-------------------------------------------------------------------------
  */
 
+#include "c.h"
 #include "postgres.h"
 
 #include "pgpa_output.h"
@@ -507,7 +508,8 @@ pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
 			return "HASH_JOIN";
 	}
 
-	Assert(false);
+	elog(ERROR, "unrecognized join strategy: %d", strategy);
+	pg_unreachable();
 }
 
 /*
@@ -536,11 +538,12 @@ pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
 			return "TID_SCAN";
 	}
 
-	Assert(false);
+	elog(ERROR, "unrecognized scan strategy: %d", strategy);
+	pg_unreachable();
 }
 
 /*
- * Get a C string that corresponds to the specified scan strategy.
+ * Get a C string that corresponds to the specified query feature type.
  */
 static char *
 pgpa_cstring_query_feature_type(pgpa_qf_type type)
@@ -557,7 +560,8 @@ pgpa_cstring_query_feature_type(pgpa_qf_type type)
 			return "SEMIJOIN_UNIQUE";
 	}
 
-	Assert(false);
+	elog(ERROR, "unrecognized query feature type: %d", type);
+	pg_unreachable();
 }
 
 /*
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
index d22ac11bf91..44adeb4511b 100644
--- a/contrib/pg_plan_advice/pgpa_walker.c
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -359,7 +359,7 @@ pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
 	{
 		int			num_aqf = list_length(active_query_features);
 
-		(void) list_truncate(active_query_features, num_aqf - 1);
+		active_query_features = list_truncate(active_query_features, num_aqf - 1);
 	}
 }
 
@@ -768,6 +768,7 @@ pgpa_walker_join_order_matches_member(pgpa_join_member *member,
 					relids = bms_add_member(relids, rti);
 				}
 			}
+			break;
 
 		case PGPA_TARGET_IDENTIFIER:
 			{
@@ -777,6 +778,7 @@ pgpa_walker_join_order_matches_member(pgpa_join_member *member,
 										  &target->rid);
 				relids = bms_make_singleton(rti);
 			}
+			break;
 	}
 
 	return bms_equal(member->scan->relids, relids);
-- 
2.43.0



  [text/plain] 0001-fixup-not-sure.txt (900B, 3-0001-fixup-not-sure.txt)
  download | inline diff:
From 4fdb5d6b047fc7fd188be23c64ae7f352b014f21 Mon Sep 17 00:00:00 2001
From: Jakub Wartak <[email protected]>
Date: Fri, 31 Oct 2025 09:34:58 +0100
Subject: [PATCH 1/2] fixup: not sure

---
 contrib/pg_plan_advice/pgpa_planner.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
index c4859c23020..5b7d2cbd9f4 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -1411,7 +1411,7 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
 		 * equivalent to allowing the non-use of either form of Gather here.
 		 */
-		if (my_entry->tag == PGPA_TAG_GATHER |
+		if (my_entry->tag == PGPA_TAG_GATHER ||
 			my_entry->tag == PGPA_TAG_GATHER_MERGE)
 		{
 			if (!just_one_rel)
-- 
2.43.0



  [text/plain] compile_errors_v1.txt (2.8K, 4-compile_errors_v1.txt)
  download | inline:
../contrib/pg_plan_advice/pgpa_ast.c: In function ‘pgpa_cstring_advice_tag’:
../contrib/pg_plan_advice/pgpa_ast.c:142:1: warning: control reaches end of non-void function [-Wreturn-type]
  142 | }
      | ^
../contrib/pg_plan_advice/pgpa_output.c: In function ‘pgpa_cstring_join_strategy’:
../contrib/pg_plan_advice/pgpa_output.c:511:1: warning: control reaches end of non-void function [-Wreturn-type]
  511 | }
      | ^
../contrib/pg_plan_advice/pgpa_output.c: In function ‘pgpa_cstring_scan_strategy’:
../contrib/pg_plan_advice/pgpa_output.c:540:1: warning: control reaches end of non-void function [-Wreturn-type]
  540 | }
      | ^
../contrib/pg_plan_advice/pgpa_output.c: In function ‘pgpa_cstring_query_feature_type’:
../contrib/pg_plan_advice/pgpa_output.c:561:1: warning: control reaches end of non-void function [-Wreturn-type]
  561 | }
      | ^
../contrib/pg_plan_advice/pgpa_walker.c: In function ‘pgpa_walk_recursively’:
../contrib/pg_plan_advice/pgpa_walker.c:362:24: warning: ignoring return value of ‘list_truncate’ declared with attribute ‘warn_unused_result’ [-Wunused-result]
  362 |                 (void) list_truncate(active_query_features, num_aqf - 1);
      |                        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In file included from ../src/include/nodes/primnodes.h:23,
                 from ../src/include/nodes/plannodes.h:23,
                 from ../contrib/pg_plan_advice/pgpa_join.h:15,
                 from ../contrib/pg_plan_advice/pgpa_walker.c:14:
../contrib/pg_plan_advice/pgpa_walker.c: In function ‘pgpa_walker_join_order_matches_member’:
../src/include/nodes/pg_list.h:482:9: warning: this statement may fall through [-Wimplicit-fallthrough=]
  482 |         for (type pointer var = 0, pointer var##__outerloop = (type pointer) 1; \
      |         ^~~
../src/include/nodes/pg_list.h:469:37: note: in expansion of macro ‘foreach_internal’
  469 | #define foreach_ptr(type, var, lst) foreach_internal(type, *, var, lst, lfirst)
      |                                     ^~~~~~~~~~~~~~~~
../contrib/pg_plan_advice/pgpa_walker.c:762:33: note: in expansion of macro ‘foreach_ptr’
  762 |                                 foreach_ptr(pgpa_advice_target, child_target, target->children)
      |                                 ^~~~~~~~~~~
../contrib/pg_plan_advice/pgpa_walker.c:772:17: note: here
  772 |                 case PGPA_TARGET_IDENTIFIER:
      |                 ^~~~
../contrib/pg_plan_advice/pgpa_planner.c: In function ‘pgpa_planner_apply_scan_advice’:
../contrib/pg_plan_advice/pgpa_planner.c:1414:35: warning: suggest parentheses around comparison in operand of ‘|’ [-Wparentheses]
 1414 |                 if (my_entry->tag == PGPA_TAG_GATHER |
      |                     ~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~

^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-10-31 12:51  Robert Haas <[email protected]>
  parent: Jakub Wartak <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-10-31 12:51 UTC (permalink / raw)
  To: Jakub Wartak <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Fri, Oct 31, 2025 at 5:59 AM Jakub Wartak
<[email protected]> wrote:
> > First, any form of user control over the planner tends to be a lightning rod for criticism around here.
>
> I do not know where this is coming from, but everybody I've talked to
> was saying this is needed to handle real enterprise databases and
> applications. I just really love it, how one could precisely adjust
> the plan with this even with the presence of heavy aliasing:

Thanks for the kind words.

I'll respond to the points about compiler warnings later.

> To attract a little attention to the $thread, the only bigger design
> (usability) question that keeps ringing in my head is how we are going
> to bind it to specific queries without even issuing any SETs(or ALTER
> USER) in the far future in the grand scheme of things. The discussed
> query id (hash), full query text comparison, maybe even strstr(query ,
> "partial hit") or regex all seem to be kind too limited in terms of
> what crazy ORMs can come up with (each query will be potentially
> slightly different, but if optimizer reference points are stable that
> should nail it good enough, but just enabling it for the very specific
> set of queries and not the others [with same aliases] is some major
> challenge).

Yeah, I haven't really dealt with this problem yet.

> Due to this, at some point I was even thinking about some hashes for
> every plan node (including hashes of subplans),
[...]
>
> and then having a way to use `somehashval3` (let's say it's SHA1) as a
> way to activate the necessary advice. Something like having a way to

This doesn't make sense to me, because it seems circular. We can't use
anything in the plan to choose which advice string to use, because the
purpose of the advice string is to influence the choice of plan. In
other words, our choice of what advice string to use has to be based
on the properties of the query, not the plan. We can implement
anything we want to do in terms of exactly how that works: we can use
the query ID, or the query text, or the query node tree.
Hypothetically, we could call out to a user-defined function and pass
the query text or the query node tree as an argument and let it do
whatever it wants to decide on an advice string. The practical problem
here is computational cost -- any computation that gets performed for
every single query is going to have to be pretty cheap to avoid
creating a performance problem. That's why I thought matching on query
ID or exact matching on query text would likely be the most practical
approaches, aside from the obvious alternative of setting and
resetting pg_plan_advice.advice manually. But I haven't really
explored this area too much yet, because I need to get all the basics
working first.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-10-31 21:17  Alastair Turner <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 2 replies; 133+ messages in thread

From: Alastair Turner @ 2025-10-31 21:17 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, 31 Oct 2025, 12:51 Robert Haas, <[email protected]> wrote:

> On Fri, Oct 31, 2025 at 5:59 AM Jakub Wartak
> <[email protected]> wrote:
> > > First, any form of user control over the planner tends to be a
> lightning rod for criticism around here.
> >
> > I do not know where this is coming from, but everybody I've talked to
> > was saying this is needed to handle real enterprise databases and
> > applications. I just really love it, how one could precisely adjust
> > the plan with this even with the presence of heavy aliasing:
>

I really like the functionality of the current patch as well, even though I
am suspicious of user control over the planner. By giving concise, precise
control over a plan, this allows people who believe they can out-plan the
planner to test their alternative, and possibly fail.

Whatever other UIs and integrations you build as you develop this towards
you goal, please keep what's currently there user accessible. Not only for
testing code, but also for testing users' belief that they know better.

Alastair


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-01 16:10  Hannu Krosing <[email protected]>
  parent: Alastair Turner <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Hannu Krosing @ 2025-11-01 16:10 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Alastair Turner <[email protected]>; PostgreSQL Hackers <[email protected]>

This weas recently shared in LinkedIn
https://www.vldb.org/pvldb/vol18/p5126-bress.pdf

For example it says that 31% of all queries are metadata queries, 78%
have LIMIT, 20% of queries have 10+ joins, with 0.52% exceeding 100
joins. , 12% of expressions have depths between 11-100 levels, some
exceeding 100. These deeply nested conditions create optimization
challenges benchmarks don't capture.etc.

This reinforces my belief thet we either should have some kind of
two-level optimization, where most queries are handled quickly but
with something to trigger a more elaborate optimisation and
investigation workflow.

Or alternatively we could just have an extra layer before the query is
sent to the database which deals with unwinding the product of
excessively stupid query generators (usually, but not always, some BI
tools :) )


On Fri, Oct 31, 2025 at 10:18 PM Alastair Turner <[email protected]> wrote:
>
>
> On Fri, 31 Oct 2025, 12:51 Robert Haas, <[email protected]> wrote:
>>
>> On Fri, Oct 31, 2025 at 5:59 AM Jakub Wartak
>> <[email protected]> wrote:
>> > > First, any form of user control over the planner tends to be a lightning rod for criticism around here.
>> >
>> > I do not know where this is coming from, but everybody I've talked to
>> > was saying this is needed to handle real enterprise databases and
>> > applications. I just really love it, how one could precisely adjust
>> > the plan with this even with the presence of heavy aliasing:
>
>
> I really like the functionality of the current patch as well, even though I am suspicious of user control over the planner. By giving concise, precise control over a plan, this allows people who believe they can out-plan the planner to test their alternative, and possibly fail.
>
> Whatever other UIs and integrations you build as you develop this towards you goal, please keep what's currently there user accessible. Not only for testing code, but also for testing users' belief that they know better.
>
> Alastair





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-03 16:18  Robert Haas <[email protected]>
  parent: Alastair Turner <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Robert Haas @ 2025-11-03 16:18 UTC (permalink / raw)
  To: Alastair Turner <[email protected]>; +Cc: Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Oct 31, 2025 at 5:17 PM Alastair Turner <[email protected]> wrote:
> I really like the functionality of the current patch as well, even though I am suspicious of user control over the planner. By giving concise, precise control over a plan, this allows people who believe they can out-plan the planner to test their alternative, and possibly fail.

Indeed. The downside of letting people control anything is that they
may leverage that control to do something bad. However, I think it is
unlikely that very many people would prefer to write an entire query
plan by hand. If you wanted to do that, why would you being using
PostgreSQL in the first place? Furthermore, if somebody does try to do
that, I expect that they will find it frustrating and difficult: the
planner considers a large number of options even for simple queries
and an absolutely vast number of options for more difficult queries,
and a human being trying possibilities one by one is only ever going
to consider a tiny fraction of those possibilities. The ideal
possibility often won't be in that small subset of the search space,
and the user will be wasting their time. If that were the design goal
of this feature, I don't think it would be worth having.

But it isn't. As I say in the README, what I consider the principal
use case is reproducing plans that you know to have worked well in the
past. Sometimes, the planner is correct for a while and then it's
wrong later. We don't need to accept the proposition that users can
out-plan the planner. We only need to accept that they can tell good
plans from bad plans better than the planner. That is a low bar to
clear. The planner never finds out what happens when the plans that it
generates are actually executed, but users do. If they are
sufficiently experienced, they can make reasonable judgements about
whether the plan they're currently getting is one they'd like to
continue getting. Of course, they may make wrong judgements even then,
because they lack knowledge or experience or just make a mistake, but
it's not a farcically unreasonable thing to do. I've basically never
wanted to write my own query plan from scratch, but I've certainly
looked at many plans over the years and judged them to be great, or
terrible, or good for now but risky in the long-term; and I'm probably
not the only human being on the planet capable of making such
judgements with some degree of competence.

> Whatever other UIs and integrations you build as you develop this towards you goal, please keep what's currently there user accessible. Not only for testing code, but also for testing users' belief that they know better.

And this is also a good point. Knowledgeable and experienced users can
look at a plan that the planner generated, feel like it's bad, and
wonder why the planner picked it. You can try to figure that out by,
for example, setting enable_SOMETHING = false and re-running EXPLAIN,
but since there aren't that many such knobs relevant to any given
query, and since changing any of those knobs can affect large swathes
of the query and not just the part you're trying to understand better,
it can actually be really difficult to understand why the planner
thought that something was the best option. Sometimes you can't even
tell whether the planner thinks that the plan you expected to be
chosen is *impossible* or just *more expensive*, which is always one
of the things that I'm keen to find out when something weird is
happening. This can make answering that question a great deal easier.
If some important index is not getting used, you can say "no, really,
I want to see what happens with this query when you plan it with that
index" -- and then it either gives you a plan that does use that
index, and you can see how much more expensive it is and why, or it
still doesn't give you a plan using that index, and you know that the
index is inapplicable to the query or unusable in general for some
reason. You don't necessarily have it as a goal to coerce the planner
in production; your goal may very well be to find out why your belief
that you know better is incorrect.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-03 16:41  Robert Haas <[email protected]>
  parent: Hannu Krosing <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2025-11-03 16:41 UTC (permalink / raw)
  To: Hannu Krosing <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Alastair Turner <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sat, Nov 1, 2025 at 12:10 PM Hannu Krosing <[email protected]> wrote:
> This reinforces my belief thet we either should have some kind of
> two-level optimization, where most queries are handled quickly but
> with something to trigger a more elaborate optimisation and
> investigation workflow.
>
> Or alternatively we could just have an extra layer before the query is
> sent to the database which deals with unwinding the product of
> excessively stupid query generators (usually, but not always, some BI
> tools :) )

I'd like to keep the focus of this thread on the patches that I'm
proposing, rather than other ideas for improving the planner. I
actually agree with you that at least the first of these things might
be a very good idea, but that would be an entirely separate project
from these patches, and I feel a lot more qualified to do this project
than that one.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-04 11:47  John Naylor <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 0 replies; 133+ messages in thread

From: John Naylor @ 2025-11-04 11:47 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Thu, Oct 30, 2025 at 9:00 PM Robert Haas <[email protected]> wrote:
> First, any form of user control over the
> planner tends to be a lightning rod for criticism around here. I've
> come to believe that's the wrong way of thinking about it: we can want
> to improve the planner over the long term and *also* want to have
> tools available to work around problems with it in the short term.

The most frustrating real-world incidents I've had were in the course
of customers planning a major version upgrade, or worse, after
upgrading and finding that a 5 minute query now takes 5 hours. I
mention this to emphasize that workarounds will be needed also to deal
with rare unintended effects that arise from our very attempts to
improve the planner.

> Further, we should not imagine that we're going to solve problems that
> have stumped other successful database projects any time in the
> foreseeable future; no product will ever get 100% of cases right, and
> you don't need to get to very obscure cases before other products
> throw up their hands just as we do.

Right.

> it seems to be super-useful for testing. We have
> a lot of regression test cases that try to coerce the planner to do a
> particular thing by manipulating enable_* GUCs, and I've spent a lot
> of time trying to do similar things by hand, either for regression
> test coverage or just private testing. This facility, even with all of
> the bugs and limitations that it currently has, is exponentially more
> powerful than frobbing enable_* GUCs. Once you get the hang of the
> advice mini-language, you can very quickly experiment with all sorts
> of plan shapes in ways that are currently very hard to do, and thereby
> find out how expensive the planner thinks those things are and which
> ones it thinks are even legal. So I see this as not only something
> that people might find useful for in production deployments, but also
> something that can potentially be really useful to advance PostgreSQL
> development.

That sounds very useful as well.

--
John Naylor
Amazon Web Services





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-04 19:54  Robert Haas <[email protected]>
  parent: Jakub Wartak <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-11-04 19:54 UTC (permalink / raw)
  To: Jakub Wartak <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Fri, Oct 31, 2025 at 5:59 AM Jakub Wartak
<[email protected]> wrote:
> My gcc-13 was nitpicking a little bit (see
> compilation_warnings_v1.txt), so attached is just a tiny diff to fix
> some of those issues. After that, clang-20 run was clean too.

Here's v2. Change log:

- Attempted to fix the compiler warnings. I didn't add elog() before
pg_unreachable() as you suggested; instead, I added a dummy return
afterwards. Let's see if that works. Also, I decided after reading the
comment for list_truncate() that what I'd done there was not going to
be acceptable, so I rewrote the code slightly. It now copies the list
when adding to it, instead of relying on the ability to use
list_truncate() to recreate the prior tstate.

- Deleted the SQL-callable pg_parse_advice function and related code.
That was useful to me early in development but I don't think anyone
will need it at this point; if you want to test whether an advice
string can be parsed, just try setting pg_plan_advice.advice.

- Fixed a couple of dumb bugs in pgpa_trove.c.

- Added a few more regression test scenarios.

- Fixed a couple of typos/thinkos.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v2-0005-Allow-for-plugin-control-over-path-generation-str.patch (55.4K, 2-v2-0005-Allow-for-plugin-control-over-path-generation-str.patch)
  download | inline diff:
From 75cff74453710f6801985b4db7787f476294c7e2 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 24 Oct 2025 15:11:47 -0400
Subject: [PATCH v2 5/6] Allow for plugin control over path generation
 strategies.

Each RelOptInfo now has a pgs_mask member which is a mask of acceptable
strategies. For most rels, this is populated from PlannerGlobal's
default_pgs_mask, which is computed from the values of the enable_*
GUCs at the start of planning.

For baserels, get_relation_info_hook can be used to adjust pgs_mask for
each new RelOptInfo, at least for rels of type RTE_RELATION. Adjusting
pgs_mask is less useful for other types of rels, but if it proves to
be necessary, we can revisit the way this hook works or add a new one.

For joinrels, two new hooks are added. joinrel_setup_hook is called each
time a joinrel is created, and one thing that can be done from that hook
is to manipulate pgs_mask for the new joinrel. join_path_setup_hook is
called each time we're about to add paths to a joinrel by considering
some particular combination of an outer rel, an inner rel, and a join
type. It can modify the pgs_mask propagated into JoinPathExtraData to
restrict strategy choice for that paricular combination of rels.

To make joinrel_setup_hook work as intended, the existing calls to
build_joinrel_partition_info are moved later in the calling functions;
this is because that function checks whether the rel's pgs_mask includes
PGS_CONSIDER_PARTITIONWISE, so we want it to only be called after
plugins have had a chance to alter pgs_mask.

Upper rels currently inherit pgs_mask from the input relation. It's
unclear that this is the most useful behavior, but at the moment there
are no hooks to allow the mask to be set in any other way.
---
 src/backend/optimizer/path/allpaths.c   |   2 +-
 src/backend/optimizer/path/costsize.c   | 221 ++++++++++++++++++------
 src/backend/optimizer/path/indxpath.c   |   4 +-
 src/backend/optimizer/path/joinpath.c   |  88 +++++++---
 src/backend/optimizer/path/tidpath.c    |   7 +-
 src/backend/optimizer/plan/createplan.c |   1 +
 src/backend/optimizer/plan/planner.c    |  54 ++++++
 src/backend/optimizer/util/pathnode.c   |  19 +-
 src/backend/optimizer/util/plancat.c    |   3 +
 src/backend/optimizer/util/relnode.c    |  43 ++++-
 src/include/nodes/pathnodes.h           |  82 ++++++++-
 src/include/optimizer/cost.h            |   4 +-
 src/include/optimizer/pathnode.h        |  11 +-
 src/include/optimizer/paths.h           |   9 +-
 14 files changed, 451 insertions(+), 97 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 349863fb194..07480282518 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -954,7 +954,7 @@ set_tablesample_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *
 		 bms_membership(root->all_query_rels) != BMS_SINGLETON) &&
 		!(GetTsmRoutine(rte->tablesample->tsmhandler)->repeatable_across_scans))
 	{
-		path = (Path *) create_material_path(rel, path);
+		path = (Path *) create_material_path(rel, path, true);
 	}
 
 	add_path(rel, path);
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 8335cf5b5c5..5e8332aa881 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -275,6 +275,7 @@ cost_seqscan(Path *path, PlannerInfo *root,
 	double		spc_seq_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = PGS_SEQSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -327,8 +328,11 @@ cost_seqscan(Path *path, PlannerInfo *root,
 		 */
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	path->disabled_nodes = enable_seqscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
 }
@@ -354,6 +358,7 @@ cost_samplescan(Path *path, PlannerInfo *root,
 				spc_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations with tablesample clauses */
 	Assert(baserel->relid > 0);
@@ -401,7 +406,11 @@ cost_samplescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -440,7 +449,8 @@ cost_gather(GatherPath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows;
 
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost;
 	path->path.total_cost = (startup_cost + run_cost);
 }
@@ -506,8 +516,8 @@ cost_gather_merge(GatherMergePath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows * 1.05;
 
-	path->path.disabled_nodes = input_disabled_nodes
-		+ (enable_gathermerge ? 0 : 1);
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER_MERGE) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost + input_startup_cost;
 	path->path.total_cost = (startup_cost + run_cost + input_total_cost);
 }
@@ -557,6 +567,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	double		pages_fetched;
 	double		rand_heap_pages;
 	double		index_pages;
+	uint64		enable_mask;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo) &&
@@ -588,8 +599,11 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 											  path->indexclauses);
 	}
 
-	/* we don't need to check enable_indexonlyscan; indxpath.c does that */
-	path->path.disabled_nodes = enable_indexscan ? 0 : 1;
+	/* is this scan type disabled? */
+	enable_mask = (indexonly ? PGS_INDEXONLYSCAN : PGS_INDEXSCAN)
+		| (path->path.parallel_workers == 0 ? PGS_CONSIDER_NONPARTIAL : 0);
+	path->path.disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 
 	/*
 	 * Call index-access-method-specific code to estimate the processing cost
@@ -1010,6 +1024,7 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	double		spc_seq_page_cost,
 				spc_random_page_cost;
 	double		T;
+	uint64		enable_mask = PGS_BITMAPSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo));
@@ -1075,6 +1090,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 
 	run_cost += cpu_run_cost;
@@ -1083,7 +1100,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = enable_bitmapscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1240,6 +1258,7 @@ cost_tidscan(Path *path, PlannerInfo *root,
 	double		ntuples;
 	ListCell   *l;
 	double		spc_random_page_cost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1261,10 +1280,10 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 		/*
 		 * We must use a TID scan for CurrentOfExpr; in any other case, we
-		 * should be generating a TID scan only if enable_tidscan=true. Also,
-		 * if CurrentOfExpr is the qual, there should be only one.
+		 * should be generating a TID scan only if TID scans are allowed.
+		 * Also, if CurrentOfExpr is the qual, there should be only one.
 		 */
-		Assert(enable_tidscan || IsA(qual, CurrentOfExpr));
+		Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0 || IsA(qual, CurrentOfExpr));
 		Assert(list_length(tidquals) == 1 || !IsA(qual, CurrentOfExpr));
 
 		if (IsA(qual, ScalarArrayOpExpr))
@@ -1316,10 +1335,14 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 	/*
 	 * There are assertions above verifying that we only reach this function
-	 * either when enable_tidscan=true or when the TID scan is the only legal
-	 * path, so it's safe to set disabled_nodes to zero here.
+	 * either when baserel->pgs_mask includes PGS_TIDSCAN or when the TID scan
+	 * is the only legal path, so we only need to consider the effects of
+	 * PGS_CONSIDER_NONPARTIAL here.
 	 */
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1349,6 +1372,7 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	double		nseqpages;
 	double		spc_random_page_cost;
 	double		spc_seq_page_cost;
+	uint64		enable_mask = PGS_TIDSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1412,8 +1436,15 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	/* we should not generate this path type when enable_tidscan=false */
-	Assert(enable_tidscan);
+	/*
+	 * We should not generate this path type when PGS_TIDSCAN is unset, but we
+	 * might need to disable this path due to PGS_CONSIDER_NONPARTIAL.
+	 */
+	Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0);
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->disabled_nodes = 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
@@ -1437,6 +1468,7 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	List	   *qpquals;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are subqueries */
 	Assert(baserel->relid > 0);
@@ -1467,7 +1499,10 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	 * SubqueryScan node, plus cpu_tuple_cost to account for selection and
 	 * projection overhead.
 	 */
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	if (path->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ (((baserel->pgs_mask & enable_mask) != enable_mask) ? 1 : 0);
 	path->path.startup_cost = path->subpath->startup_cost;
 	path->path.total_cost = path->subpath->total_cost;
 
@@ -1518,6 +1553,7 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1558,7 +1594,10 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1580,6 +1619,7 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1615,7 +1655,10 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1635,6 +1678,7 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are values lists */
 	Assert(baserel->relid > 0);
@@ -1663,7 +1707,10 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1686,6 +1733,7 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are CTEs */
 	Assert(baserel->relid > 0);
@@ -1711,7 +1759,10 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1728,6 +1779,7 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are Tuplestores */
 	Assert(baserel->relid > 0);
@@ -1749,7 +1801,10 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	cpu_per_tuple += cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1766,6 +1821,7 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to RTE_RESULT base relations */
 	Assert(baserel->relid > 0);
@@ -1784,7 +1840,10 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1802,6 +1861,7 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	Cost		startup_cost;
 	Cost		total_cost;
 	double		total_rows;
+	uint64		enable_mask = 0;
 
 	/* We probably have decent estimates for the non-recursive term */
 	startup_cost = nrterm->startup_cost;
@@ -1824,7 +1884,10 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	 */
 	total_cost += cpu_tuple_cost * total_rows;
 
-	runion->disabled_nodes = nrterm->disabled_nodes + rterm->disabled_nodes;
+	if (runion->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	runion->disabled_nodes =
+		(runion->parent->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	runion->startup_cost = startup_cost;
 	runion->total_cost = total_cost;
 	runion->rows = total_rows;
@@ -2094,7 +2157,11 @@ cost_incremental_sort(Path *path,
 
 	path->rows = input_tuples;
 
-	/* should not generate these paths when enable_incremental_sort=false */
+	/*
+	 * We should not generate these paths when enable_incremental_sort=false.
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	Assert(enable_incremental_sort);
 	path->disabled_nodes = input_disabled_nodes;
 
@@ -2132,6 +2199,10 @@ cost_sort(Path *path, PlannerInfo *root,
 
 	startup_cost += input_cost;
 
+	/*
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	path->rows = tuples;
 	path->disabled_nodes = input_disabled_nodes + (enable_sort ? 0 : 1);
 	path->startup_cost = startup_cost;
@@ -2223,9 +2294,15 @@ append_nonpartial_cost(List *subpaths, int numpaths, int parallel_workers)
 void
 cost_append(AppendPath *apath, PlannerInfo *root)
 {
+	RelOptInfo *rel = apath->path.parent;
 	ListCell   *l;
+	uint64		enable_mask = PGS_APPEND;
+
+	if (apath->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	apath->path.disabled_nodes = 0;
+	apath->path.disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	apath->path.startup_cost = 0;
 	apath->path.total_cost = 0;
 	apath->path.rows = 0;
@@ -2435,11 +2512,16 @@ cost_merge_append(Path *path, PlannerInfo *root,
 				  Cost input_startup_cost, Cost input_total_cost,
 				  double tuples)
 {
+	RelOptInfo *rel = path->parent;
 	Cost		startup_cost = 0;
 	Cost		run_cost = 0;
 	Cost		comparison_cost;
 	double		N;
 	double		logN;
+	uint64		enable_mask = PGS_MERGE_APPEND;
+
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/*
 	 * Avoid log(0)...
@@ -2462,7 +2544,9 @@ cost_merge_append(Path *path, PlannerInfo *root,
 	 */
 	run_cost += cpu_tuple_cost * APPEND_CPU_COST_MULTIPLIER * tuples;
 
-	path->disabled_nodes = input_disabled_nodes;
+	path->disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
+	path->disabled_nodes += input_disabled_nodes;
 	path->startup_cost = startup_cost + input_startup_cost;
 	path->total_cost = startup_cost + run_cost + input_total_cost;
 }
@@ -2481,7 +2565,7 @@ cost_merge_append(Path *path, PlannerInfo *root,
  */
 void
 cost_material(Path *path,
-			  int input_disabled_nodes,
+			  bool enabled, int input_disabled_nodes,
 			  Cost input_startup_cost, Cost input_total_cost,
 			  double tuples, int width)
 {
@@ -2490,6 +2574,10 @@ cost_material(Path *path,
 	double		nbytes = relation_byte_size(tuples, width);
 	double		work_mem_bytes = work_mem * (Size) 1024;
 
+	if (path->parallel_workers == 0 &&
+		(path->parent->pgs_mask & PGS_CONSIDER_NONPARTIAL) == 0)
+		enabled = false;
+
 	path->rows = tuples;
 
 	/*
@@ -2519,7 +2607,7 @@ cost_material(Path *path,
 		run_cost += seq_page_cost * npages;
 	}
 
-	path->disabled_nodes = input_disabled_nodes + (enable_material ? 0 : 1);
+	path->disabled_nodes = input_disabled_nodes + (enabled ? 0 : 1);
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -3271,7 +3359,7 @@ cost_group(Path *path, PlannerInfo *root,
  */
 void
 initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
-					  JoinType jointype,
+					  JoinType jointype, uint64 enable_mask,
 					  Path *outer_path, Path *inner_path,
 					  JoinPathExtraData *extra)
 {
@@ -3285,7 +3373,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Cost		inner_rescan_run_cost;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_nestloop ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
@@ -3685,7 +3773,19 @@ initial_cost_mergejoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Assert(outerstartsel <= outerendsel);
 	Assert(innerstartsel <= innerendsel);
 
-	disabled_nodes = enable_mergejoin ? 0 : 1;
+	/*
+	 * We don't decide whether to materialize the inner path until we get to
+	 * final_cost_mergejoin(), so we don't know whether to check the pgs_mask
+	 * again PGS_MERGEJOIN_PLAIN or PGS_MERGEJOIN_MATERIALIZE. Instead, we
+	 * just account for any child nodes here and assume that this node is not
+	 * itslef disabled; we can sort out the details in final_cost_mergejoin().
+	 *
+	 * (We could be more precise here by setting disabled_nodes to 1 at this
+	 * stage if both PGS_MERGEJOIN_PLAIN and PGS_MERGEJOIN_MATERIALIZE are
+	 * disabled, but that seems to against the idea of making this function
+	 * produce a quick, optimistic approximation of the final cost.)
+	 */
+	disabled_nodes = 0;
 
 	/* cost of source data */
 
@@ -3864,9 +3964,7 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	double		mergejointuples,
 				rescannedtuples;
 	double		rescanratio;
-
-	/* Set the number of disabled nodes. */
-	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+	uint64		enable_mask = 0;
 
 	/* Protect some assumptions below that rowcounts aren't zero */
 	if (inner_path_rows <= 0)
@@ -3996,16 +4094,20 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 		path->materialize_inner = false;
 
 	/*
-	 * Prefer materializing if it looks cheaper, unless the user has asked to
-	 * suppress materialization.
+	 * If merge joins with materialization are enabled, then choose
+	 * materialization if either (a) it looks cheaper or (b) merge joins
+	 * without materialization are disabled.
 	 */
-	else if (enable_material && mat_inner_cost < bare_inner_cost)
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 (mat_inner_cost < bare_inner_cost ||
+			  (extra->pgs_mask & PGS_MERGEJOIN_PLAIN) == 0))
 		path->materialize_inner = true;
 
 	/*
-	 * Even if materializing doesn't look cheaper, we *must* do it if the
-	 * inner path is to be used directly (without sorting) and it doesn't
-	 * support mark/restore.
+	 * Regardless of what plan shapes are enabled and what the costs seem to
+	 * be, we *must* materialize it if the inner path is to be used directly
+	 * (without sorting) and it doesn't support mark/restore. Planner failure
+	 * is not an option!
 	 *
 	 * Since the inner side must be ordered, and only Sorts and IndexScans can
 	 * create order to begin with, and they both support mark/restore, you
@@ -4013,10 +4115,6 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * merge joins can *preserve* the order of their inputs, so they can be
 	 * selected as the input of a mergejoin, and they don't support
 	 * mark/restore at present.
-	 *
-	 * We don't test the value of enable_material here, because
-	 * materialization is required for correctness in this case, and turning
-	 * it off does not entitle us to deliver an invalid plan.
 	 */
 	else if (innersortkeys == NIL &&
 			 !ExecSupportsMarkRestore(inner_path))
@@ -4030,10 +4128,11 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * though.
 	 *
 	 * Since materialization is a performance optimization in this case,
-	 * rather than necessary for correctness, we skip it if enable_material is
-	 * off.
+	 * rather than necessary for correctness, we skip it if materialization is
+	 * switched off.
 	 */
-	else if (enable_material && innersortkeys != NIL &&
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 innersortkeys != NIL &&
 			 relation_byte_size(inner_path_rows,
 								inner_path->pathtarget->width) >
 			 work_mem * (Size) 1024)
@@ -4041,11 +4140,29 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	else
 		path->materialize_inner = false;
 
-	/* Charge the right incremental cost for the chosen case */
+	/* Get the number of disabled nodes, not yet including this one. */
+	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+
+	/*
+	 * Charge the right incremental cost for the chosen case, and update
+	 * enable_mask as appropriate.
+	 */
 	if (path->materialize_inner)
+	{
 		run_cost += mat_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
 	else
+	{
 		run_cost += bare_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_PLAIN;
+	}
+
+	/* Incremental count of disabled nodes if this node is disabled. */
+	if (path->jpath.path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	if ((extra->pgs_mask & enable_mask) != enable_mask)
+		++path->jpath.path.disabled_nodes;
 
 	/* CPU costs */
 
@@ -4183,9 +4300,13 @@ initial_cost_hashjoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	int			numbatches;
 	int			num_skew_mcvs;
 	size_t		space_allowed;	/* unused */
+	uint64		enable_mask = PGS_HASHJOIN;
+
+	if (outer_path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_hashjoin ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index edc6d2ac1d3..a701c847cb5 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -2233,8 +2233,8 @@ check_index_only(RelOptInfo *rel, IndexOptInfo *index)
 	ListCell   *lc;
 	int			i;
 
-	/* Index-only scans must be enabled */
-	if (!enable_indexonlyscan)
+	/* If we're not allowed to consider index-only scans, give up now */
+	if ((rel->pgs_mask & PGS_CONSIDER_INDEXONLY) == 0)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index ea5b6415186..388d8456ff6 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -29,8 +29,9 @@
 #include "utils/lsyscache.h"
 #include "utils/typcache.h"
 
-/* Hook for plugins to get control in add_paths_to_joinrel() */
+/* Hooks for plugins to get control in add_paths_to_joinrel() */
 set_join_pathlist_hook_type set_join_pathlist_hook = NULL;
+join_path_setup_hook_type join_path_setup_hook = NULL;
 
 /*
  * Paths parameterized by a parent rel can be considered to be parameterized
@@ -151,6 +152,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	extra.mergeclause_list = NIL;
 	extra.sjinfo = sjinfo;
 	extra.param_source_rels = NULL;
+	extra.pgs_mask = joinrel->pgs_mask;
 
 	/*
 	 * See if the inner relation is provably unique for this outer rel.
@@ -207,13 +209,38 @@ add_paths_to_joinrel(PlannerInfo *root,
 	if (jointype == JOIN_UNIQUE_OUTER || jointype == JOIN_UNIQUE_INNER)
 		jointype = JOIN_INNER;
 
+	/*
+	 * Give extensions a chance to take control. In particular, an extension
+	 * might want to modify extra.pgs_mask. It's possible to override pgs_mask
+	 * on a query-wide basis using join_search_hook, or for a particular
+	 * relation using joinrel_setup_hook, but extensions that want to provide
+	 * different advice for the same joinrel based on the choice of innerrel
+	 * and outerrel will need to use this hook.
+	 *
+	 * A very simple way for an extension to use this hook is to set
+	 * extra.pgs_mask = 0, if it simply doesn't want any of the paths
+	 * generated by this call to add_paths_to_joinrel() to be selected. An
+	 * extension could use this technique to constrain the join order, since
+	 * it could thereby arrange to reject all paths from join orders that it
+	 * does not like. An extension can also selectively clear bits from
+	 * extra.pgs_mask to rule out specific techniques for specific joins, or
+	 * even replace the mask entirely.
+	 *
+	 * NB: Below this point, this function should be careful to reference
+	 * extra.pgs_mask rather than rel->pgs_mask to avoid disregarding any
+	 * changes made by the hook we're about to call.
+	 */
+	if (join_path_setup_hook)
+		join_path_setup_hook(root, joinrel, outerrel, innerrel,
+							 jointype, &extra);
+
 	/*
 	 * Find potential mergejoin clauses.  We can skip this if we are not
 	 * interested in doing a mergejoin.  However, mergejoin may be our only
-	 * way of implementing a full outer join, so override enable_mergejoin if
-	 * it's a full join.
+	 * way of implementing a full outer join, so in that case we don't care
+	 * whether mergejoins are disabled.
 	 */
-	if (enable_mergejoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_MERGEJOIN_ANY) != 0 || jointype == JOIN_FULL)
 		extra.mergeclause_list = select_mergejoin_clauses(root,
 														  joinrel,
 														  outerrel,
@@ -321,10 +348,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 
 	/*
 	 * 4. Consider paths where both outer and inner relations must be hashed
-	 * before being joined.  As above, disregard enable_hashjoin for full
-	 * joins, because there may be no other alternative.
+	 * before being joined.  As above, when it's a full join, we must try this
+	 * even when the path type is disabled, because it may be our only option.
 	 */
-	if (enable_hashjoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_HASHJOIN) != 0 || jointype == JOIN_FULL)
 		hash_inner_and_outer(root, joinrel, outerrel, innerrel,
 							 jointype, &extra);
 
@@ -333,7 +360,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	 * to the same server and assigned to the same user to check access
 	 * permissions as, give the FDW a chance to push down joins.
 	 */
-	if (joinrel->fdwroutine &&
+	if ((extra.pgs_mask & PGS_FOREIGNJOIN) != 0 && joinrel->fdwroutine &&
 		joinrel->fdwroutine->GetForeignJoinPaths)
 		joinrel->fdwroutine->GetForeignJoinPaths(root, joinrel,
 												 outerrel, innerrel,
@@ -342,8 +369,13 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * 6. Finally, give extensions a chance to manipulate the path list.  They
 	 * could add new paths (such as CustomPaths) by calling add_path(), or
-	 * add_partial_path() if parallel aware.  They could also delete or modify
-	 * paths added by the core code.
+	 * add_partial_path() if parallel aware.
+	 *
+	 * In theory, extensions could also use this hook to delete or modify
+	 * paths added by the core code, but in practice this is difficult to make
+	 * work, since it's too late to get back any paths that have already been
+	 * discarded by add_path() or add_partial_path(). If you're trying to
+	 * suppress paths, consider using join_path_setup_hook instead.
 	 */
 	if (set_join_pathlist_hook)
 		set_join_pathlist_hook(root, joinrel, outerrel, innerrel,
@@ -690,7 +722,7 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel,
 	List	   *ph_lateral_vars;
 
 	/* Obviously not if it's disabled */
-	if (!enable_memoize)
+	if ((extra->pgs_mask & PGS_NESTLOOP_MEMOIZE) == 0)
 		return NULL;
 
 	/*
@@ -845,6 +877,7 @@ try_nestloop_path(PlannerInfo *root,
 				  Path *inner_path,
 				  List *pathkeys,
 				  JoinType jointype,
+				  uint64 nestloop_subtype,
 				  JoinPathExtraData *extra)
 {
 	Relids		required_outer;
@@ -927,6 +960,7 @@ try_nestloop_path(PlannerInfo *root,
 	 * methodology worthwhile.
 	 */
 	initial_cost_nestloop(root, &workspace, jointype,
+						  nestloop_subtype | PGS_CONSIDER_NONPARTIAL,
 						  outer_path, inner_path, extra);
 
 	if (add_path_precheck(joinrel, workspace.disabled_nodes,
@@ -964,6 +998,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 						  Path *inner_path,
 						  List *pathkeys,
 						  JoinType jointype,
+						  uint64 nestloop_subtype,
 						  JoinPathExtraData *extra)
 {
 	JoinCostWorkspace workspace;
@@ -1011,7 +1046,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 	 * Before creating a path, get a quick lower bound on what it is likely to
 	 * cost.  Bail out right away if it looks terrible.
 	 */
-	initial_cost_nestloop(root, &workspace, jointype,
+	initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype,
 						  outer_path, inner_path, extra);
 	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
 								   workspace.total_cost, pathkeys))
@@ -1859,14 +1894,14 @@ match_unsorted_outer(PlannerInfo *root,
 	if (nestjoinOK)
 	{
 		/*
-		 * Consider materializing the cheapest inner path, unless
-		 * enable_material is off or the path in question materializes its
-		 * output anyway.
+		 * Consider materializing the cheapest inner path, unless that is
+		 * disabled or the path in question materializes its output anyway.
 		 */
-		if (enable_material && inner_cheapest_total != NULL &&
+		if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+			inner_cheapest_total != NULL &&
 			!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 			matpath = (Path *)
-				create_material_path(innerrel, inner_cheapest_total);
+				create_material_path(innerrel, inner_cheapest_total, true);
 	}
 
 	foreach(lc1, outerrel->pathlist)
@@ -1909,6 +1944,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  innerpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_PLAIN,
 								  extra);
 
 				/*
@@ -1925,6 +1961,7 @@ match_unsorted_outer(PlannerInfo *root,
 									  mpath,
 									  merge_pathkeys,
 									  jointype,
+									  PGS_NESTLOOP_MEMOIZE,
 									  extra);
 			}
 
@@ -1936,6 +1973,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  matpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_MATERIALIZE,
 								  extra);
 		}
 
@@ -2052,16 +2090,17 @@ consider_parallel_nestloop(PlannerInfo *root,
 
 	/*
 	 * Consider materializing the cheapest inner path, unless: 1)
-	 * enable_material is off, 2) the cheapest inner path is not
+	 * materialization is disabled here, 2) the cheapest inner path is not
 	 * parallel-safe, 3) the cheapest inner path is parameterized by the outer
 	 * rel, or 4) the cheapest inner path materializes its output anyway.
 	 */
-	if (enable_material && inner_cheapest_total->parallel_safe &&
+	if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+		inner_cheapest_total->parallel_safe &&
 		!PATH_PARAM_BY_REL(inner_cheapest_total, outerrel) &&
 		!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 	{
 		matpath = (Path *)
-			create_material_path(innerrel, inner_cheapest_total);
+			create_material_path(innerrel, inner_cheapest_total, true);
 		Assert(matpath->parallel_safe);
 	}
 
@@ -2091,7 +2130,8 @@ consider_parallel_nestloop(PlannerInfo *root,
 				continue;
 
 			try_partial_nestloop_path(root, joinrel, outerpath, innerpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_PLAIN, extra);
 
 			/*
 			 * Try generating a memoize path and see if that makes the nested
@@ -2102,13 +2142,15 @@ consider_parallel_nestloop(PlannerInfo *root,
 									 extra);
 			if (mpath != NULL)
 				try_partial_nestloop_path(root, joinrel, outerpath, mpath,
-										  pathkeys, jointype, extra);
+										  pathkeys, jointype,
+										  PGS_NESTLOOP_MEMOIZE, extra);
 		}
 
 		/* Also consider materialized form of the cheapest inner path */
 		if (matpath != NULL)
 			try_partial_nestloop_path(root, joinrel, outerpath, matpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_MATERIALIZE, extra);
 	}
 }
 
diff --git a/src/backend/optimizer/path/tidpath.c b/src/backend/optimizer/path/tidpath.c
index 2bfb338b81c..639a0d3cadb 100644
--- a/src/backend/optimizer/path/tidpath.c
+++ b/src/backend/optimizer/path/tidpath.c
@@ -500,18 +500,19 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	List	   *tidquals;
 	List	   *tidrangequals;
 	bool		isCurrentOf;
+	bool		enabled = (rel->pgs_mask & PGS_TIDSCAN) != 0;
 
 	/*
 	 * If any suitable quals exist in the rel's baserestrict list, generate a
 	 * plain (unparameterized) TidPath with them.
 	 *
-	 * We skip this when enable_tidscan = false, except when the qual is
+	 * We skip this when TID scans are disabled, except when the qual is
 	 * CurrentOfExpr. In that case, a TID scan is the only correct path.
 	 */
 	tidquals = TidQualFromRestrictInfoList(root, rel->baserestrictinfo, rel,
 										   &isCurrentOf);
 
-	if (tidquals != NIL && (enable_tidscan || isCurrentOf))
+	if (tidquals != NIL && (enabled || isCurrentOf))
 	{
 		/*
 		 * This path uses no join clauses, but it could still have required
@@ -533,7 +534,7 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	}
 
 	/* Skip the rest if TID scans are disabled. */
-	if (!enable_tidscan)
+	if (!enabled)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 88b4c5901b0..f47f9aab47a 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -6526,6 +6526,7 @@ materialize_finished_plan(Plan *subplan)
 
 	/* Set cost data */
 	cost_material(&matpath,
+				  enable_material,
 				  subplan->disabled_nodes,
 				  subplan->startup_cost,
 				  subplan->total_cost,
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 8b1ab847f39..e2683b2481f 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -462,6 +462,53 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 		tuple_fraction = 0.0;
 	}
 
+	/*
+	 * Compute the initial path generation strategy mask.
+	 *
+	 * Some strategies, such as PGS_FOREIGNJOIN, have no corresponding enable_*
+	 * GUC, and so the corresponding bits are always set in the default
+	 * strategy mask.
+	 *
+	 * It may seem surprising that enable_indexscan sets both PGS_INDEXSCAN
+	 * and PGS_INDEXONLYSCAN. However, the historical behavior of this GUC
+	 * corresponds to this exactly: enable_indexscan=off disables both
+	 * index-scan and index-only scan paths, whereas enable_indexonlyscan=off
+	 * converts the index-only scan paths that we would have considered into
+	 * index scan paths.
+	 */
+	glob->default_pgs_mask = PGS_APPEND | PGS_MERGE_APPEND | PGS_FOREIGNJOIN |
+		PGS_GATHER | PGS_CONSIDER_NONPARTIAL;
+	if (enable_tidscan)
+		glob->default_pgs_mask |= PGS_TIDSCAN;
+	if (enable_seqscan)
+		glob->default_pgs_mask |= PGS_SEQSCAN;
+	if (enable_indexscan)
+		glob->default_pgs_mask |= PGS_INDEXSCAN | PGS_INDEXONLYSCAN;
+	if (enable_indexonlyscan)
+		glob->default_pgs_mask |= PGS_CONSIDER_INDEXONLY;
+	if (enable_bitmapscan)
+		glob->default_pgs_mask |= PGS_BITMAPSCAN;
+	if (enable_mergejoin)
+	{
+		glob->default_pgs_mask |= PGS_MERGEJOIN_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
+	if (enable_nestloop)
+	{
+		glob->default_pgs_mask |= PGS_NESTLOOP_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MATERIALIZE;
+		if (enable_memoize)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MEMOIZE;
+	}
+	if (enable_hashjoin)
+		glob->default_pgs_mask |= PGS_HASHJOIN;
+	if (enable_gathermerge)
+		glob->default_pgs_mask |= PGS_GATHER_MERGE;
+	if (enable_partitionwise_join)
+		glob->default_pgs_mask |= PGS_CONSIDER_PARTITIONWISE;
+
 	/* Allow plugins to take control after we've initialized "glob" */
 	if (planner_setup_hook)
 		(*planner_setup_hook) (glob, parse, query_string, &tuple_fraction, es);
@@ -3954,6 +4001,9 @@ make_grouping_rel(PlannerInfo *root, RelOptInfo *input_rel,
 		is_parallel_safe(root, (Node *) havingQual))
 		grouped_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed */
+	grouped_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the grouped rel.
 	 */
@@ -5348,6 +5398,9 @@ create_ordered_paths(PlannerInfo *root,
 	if (input_rel->consider_parallel && target_parallel_safe)
 		ordered_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed. */
+	ordered_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the ordered_rel.
 	 */
@@ -7428,6 +7481,7 @@ create_partial_grouping_paths(PlannerInfo *root,
 											grouped_rel->relids);
 	partially_grouped_rel->consider_parallel =
 		grouped_rel->consider_parallel;
+	partially_grouped_rel->pgs_mask = grouped_rel->pgs_mask;
 	partially_grouped_rel->reloptkind = grouped_rel->reloptkind;
 	partially_grouped_rel->serverid = grouped_rel->serverid;
 	partially_grouped_rel->userid = grouped_rel->userid;
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index c0a9811b130..eb57f0538ba 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1658,7 +1658,7 @@ create_group_result_path(PlannerInfo *root, RelOptInfo *rel,
  *	  pathnode.
  */
 MaterialPath *
-create_material_path(RelOptInfo *rel, Path *subpath)
+create_material_path(RelOptInfo *rel, Path *subpath, bool enabled)
 {
 	MaterialPath *pathnode = makeNode(MaterialPath);
 
@@ -1677,6 +1677,7 @@ create_material_path(RelOptInfo *rel, Path *subpath)
 	pathnode->subpath = subpath;
 
 	cost_material(&pathnode->path,
+				  enabled,
 				  subpath->disabled_nodes,
 				  subpath->startup_cost,
 				  subpath->total_cost,
@@ -1729,8 +1730,15 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath,
 	pathnode->est_unique_keys = 0.0;
 	pathnode->est_hit_ratio = 0.0;
 
-	/* we should not generate this path type when enable_memoize=false */
-	Assert(enable_memoize);
+	/*
+	 * We should not be asked to generate this path type when memoization is
+	 * disabled, so set our count of disabled nodes equal to the subpath's
+	 * count.
+	 *
+	 * It would be nice to also Assert that memoization is enabled, but the
+	 * value of enable_memoize is not controlling: what we would need to check
+	 * is that the JoinPathExtraData's pgs_mask included PGS_NESTLOOP_MEMOIZE.
+	 */
 	pathnode->path.disabled_nodes = subpath->disabled_nodes;
 
 	/*
@@ -3964,13 +3972,16 @@ reparameterize_path(PlannerInfo *root, Path *path,
 			{
 				MaterialPath *mpath = (MaterialPath *) path;
 				Path	   *spath = mpath->subpath;
+				bool		enabled;
 
 				spath = reparameterize_path(root, spath,
 											required_outer,
 											loop_count);
+				enabled =
+					(mpath->path.disabled_nodes <= spath->disabled_nodes);
 				if (spath == NULL)
 					return NULL;
-				return (Path *) create_material_path(rel, spath);
+				return (Path *) create_material_path(rel, spath, enabled);
 			}
 		case T_Memoize:
 			{
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index d950bd93002..ffd7bb3b221 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -557,6 +557,9 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 	 * Allow a plugin to editorialize on the info we obtained from the
 	 * catalogs.  Actions might include altering the assumed relation size,
 	 * removing an index, or adding a hypothetical index to the indexlist.
+	 *
+	 * An extension can also modify rel->pgs_mask here to control path
+	 * generation.
 	 */
 	if (get_relation_info_hook)
 		(*get_relation_info_hook) (root, relationObjectId, inhparent, rel);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 1158bc194c3..034d0c9c87a 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -47,6 +47,9 @@ typedef struct JoinHashEntry
 	RelOptInfo *join_rel;
 } JoinHashEntry;
 
+/* Hook for plugins to get control during joinrel setup */
+joinrel_setup_hook_type joinrel_setup_hook = NULL;
+
 static void build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 								RelOptInfo *input_rel,
 								SpecialJoinInfo *sjinfo,
@@ -225,6 +228,7 @@ build_simple_rel(PlannerInfo *root, int relid, RelOptInfo *parent)
 	rel->consider_startup = (root->tuple_fraction > 0);
 	rel->consider_param_startup = false;	/* might get changed later */
 	rel->consider_parallel = false; /* might get changed later */
+	rel->pgs_mask = root->glob->default_pgs_mask;
 	rel->reltarget = create_empty_pathtarget();
 	rel->pathlist = NIL;
 	rel->ppilist = NIL;
@@ -822,6 +826,7 @@ build_join_rel(PlannerInfo *root,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -934,10 +939,6 @@ build_join_rel(PlannerInfo *root,
 	 */
 	joinrel->has_eclass_joins = has_relevant_eclass_joinclause(root, joinrel);
 
-	/* Store the partition information. */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/*
 	 * Set estimates of the joinrel's size.
 	 */
@@ -963,6 +964,18 @@ build_join_rel(PlannerInfo *root,
 		is_parallel_safe(root, (Node *) joinrel->reltarget->exprs))
 		joinrel->consider_parallel = true;
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Store the partition information. */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* Add the joinrel to the PlannerInfo. */
 	add_join_rel(root, joinrel);
 
@@ -1019,6 +1032,7 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -1102,10 +1116,6 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	 */
 	joinrel->has_eclass_joins = parent_joinrel->has_eclass_joins;
 
-	/* Is the join between partitions itself partitioned? */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/* Child joinrel is parallel safe if parent is parallel safe. */
 	joinrel->consider_parallel = parent_joinrel->consider_parallel;
 
@@ -1113,6 +1123,20 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	set_joinrel_size_estimates(root, joinrel, outer_rel, inner_rel,
 							   sjinfo, restrictlist);
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel,
+	 * although the latter would be better done in the parent joinrel rather
+	 * than here.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Is the join between partitions itself partitioned? */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* We build the join only once. */
 	Assert(!find_join_rel(root, joinrel->relids));
 
@@ -1602,6 +1626,7 @@ fetch_upper_rel(PlannerInfo *root, UpperRelationKind kind, Relids relids)
 	upperrel = makeNode(RelOptInfo);
 	upperrel->reloptkind = RELOPT_UPPER_REL;
 	upperrel->relids = bms_copy(relids);
+	upperrel->pgs_mask = root->glob->default_pgs_mask;
 
 	/* cheap startup cost is interesting iff not all tuples to be retrieved */
 	upperrel->consider_startup = (root->tuple_fraction > 0);
@@ -2118,7 +2143,7 @@ build_joinrel_partition_info(PlannerInfo *root,
 	PartitionScheme part_scheme;
 
 	/* Nothing to do if partitionwise join technique is disabled. */
-	if (!enable_partitionwise_join)
+	if ((joinrel->pgs_mask & PGS_CONSIDER_PARTITIONWISE) == 0)
 	{
 		Assert(!IS_PARTITIONED_REL(joinrel));
 		return;
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 75a70489e5a..4746d3c43c4 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -22,6 +22,79 @@
 #include "nodes/parsenodes.h"
 #include "storage/block.h"
 
+/*
+ * Path generation strategies.
+ *
+ * These constants are used to specify the set of strategies that the planner
+ * should use, either for the query as a whole or for a specific baserel or
+ * joinrel. The various planner-related enable_* GUCs are used to set the
+ * PlannerGlobal's default_pgs_mask, and that in turn is used to set each
+ * RelOptInfo's pgs_mask. In both cases, extensions can use hooks to modify the
+ * default value.  Not every strategy listed here has a corresponding enable_*
+ * GUC; those that don't are always allowed unless disabled by an extension.
+ * Not all strategies are relevant for every RelOptInfo; e.g. PGS_SEQSCAN
+ * doesn't affect joinrels one way or the other.
+ *
+ * In most cases, disabling a path generation strategy merely means that any
+ * paths generated using that strategy are marked as disabled, but in some
+ * cases, path generation is skipped altogether. The latter strategy is only
+ * permissible when it can't result in planner failure -- for instance, we
+ * couldn't do this for sequential scans on a plain rel, because there might
+ * not be any other possible path. Nevertheless, the behaviors in each
+ * individual case are to some extent the result of historical accident,
+ * chosen to match the preexisting behaviors of the enable_* GUCs.
+ *
+ * In a few cases, we have more than one bit for the same strategy, controlling
+ * different aspects of the planner behavior. When PGS_CONSIDER_INDEXONLY is
+ * unset, we don't even consider index-only scans, and any such scans that
+ * would have been generated become index scans instead. On the other hand,
+ * unsetting PGS_INDEXSCAN or PGS_INDEXONLYSCAN causes generated paths of the
+ * corresponding types to be marked as disabled. Similarly, unsetting
+ * PGS_CONSIDER_PARTITIONWISE prevents any sort of thinking about partitionwise
+ * joins for the current rel, which incidentally will preclude higher-level
+ * joinrels from building parititonwise paths using paths taken from the
+ * current rel's children. On the other hand, unsetting PGS_APPEND or
+ * PGS_MERGE_APPEND will only arrange to disable paths of the corresponding
+ * types if they are generated at the level of the current rel.
+ *
+ * Finally, unsetting PGS_CONSIDER_NONPARTIAL disables all non-partial paths
+ * except those that use Gather or Gather Merge. In most other cases, a
+ * plugin can nudge the planner toward a particular strategy by disabling
+ * all of the others, but that doesn't work here: unsetting PGS_SEQSCAN,
+ * for instance, would disable both partial and non-partial sequential scans.
+ */
+#define PGS_SEQSCAN					0x00000001
+#define PGS_INDEXSCAN				0x00000002
+#define PGS_INDEXONLYSCAN			0x00000004
+#define PGS_BITMAPSCAN				0x00000008
+#define PGS_TIDSCAN					0x00000010
+#define PGS_FOREIGNJOIN				0x00000020
+#define PGS_MERGEJOIN_PLAIN			0x00000040
+#define PGS_MERGEJOIN_MATERIALIZE	0x00000080
+#define PGS_NESTLOOP_PLAIN			0x00000100
+#define PGS_NESTLOOP_MATERIALIZE	0x00000200
+#define PGS_NESTLOOP_MEMOIZE		0x00000400
+#define PGS_HASHJOIN				0x00000800
+#define PGS_APPEND					0x00001000
+#define PGS_MERGE_APPEND			0x00002000
+#define PGS_GATHER					0x00004000
+#define PGS_GATHER_MERGE			0x00008000
+#define PGS_CONSIDER_INDEXONLY		0x00010000
+#define PGS_CONSIDER_PARTITIONWISE	0x00020000
+#define PGS_CONSIDER_NONPARTIAL		0x00040000
+
+/*
+ * Convenience macros for useful combination of the bits defined above.
+ */
+#define PGS_SCAN_ANY		\
+	(PGS_SEQSCAN | PGS_INDEXSCAN | PGS_INDEXONLYSCAN | PGS_BITMAPSCAN | \
+	 PGS_TIDSCAN)
+#define PGS_MERGEJOIN_ANY	\
+	(PGS_MERGEJOIN_PLAIN | PGS_MERGEJOIN_MATERIALIZE)
+#define PGS_NESTLOOP_ANY	\
+	(PGS_NESTLOOP_PLAIN | PGS_NESTLOOP_MATERIALIZE | PGS_NESTLOOP_MEMOIZE)
+#define PGS_JOIN_ANY		\
+	(PGS_FOREIGNJOIN | PGS_MERGEJOIN_ANY | PGS_NESTLOOP_ANY | PGS_HASHJOIN)
 
 /*
  * Relids
@@ -186,6 +259,9 @@ typedef struct PlannerGlobal
 	/* worst PROPARALLEL hazard level */
 	char		maxParallelHazard;
 
+	/* mask of allowed path generation strategies */
+	uint64		default_pgs_mask;
+
 	/* partition descriptors */
 	PartitionDirectory partition_directory pg_node_attr(read_write_ignore);
 
@@ -939,7 +1015,7 @@ typedef struct RelOptInfo
 	Cardinality rows;
 
 	/*
-	 * per-relation planner control flags
+	 * per-relation planner control
 	 */
 	/* keep cheap-startup-cost paths? */
 	bool		consider_startup;
@@ -947,6 +1023,8 @@ typedef struct RelOptInfo
 	bool		consider_param_startup;
 	/* consider parallel paths? */
 	bool		consider_parallel;
+	/* path generation strategy mask */
+	uint64		pgs_mask;
 
 	/*
 	 * default result targetlist for Paths scanning this relation; list of
@@ -3505,6 +3583,7 @@ typedef struct SemiAntiJoinFactors
  * sjinfo is extra info about special joins for selectivity estimation
  * semifactors is as shown above (only valid for SEMI/ANTI/inner_unique joins)
  * param_source_rels are OK targets for parameterization of result paths
+ * pgs_mask is a bitmask of PGS_* constants to limit the join strategy
  */
 typedef struct JoinPathExtraData
 {
@@ -3514,6 +3593,7 @@ typedef struct JoinPathExtraData
 	SpecialJoinInfo *sjinfo;
 	SemiAntiJoinFactors semifactors;
 	Relids		param_source_rels;
+	uint64		pgs_mask;
 } JoinPathExtraData;
 
 /*
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index b523bcda8f3..2d80462bece 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -125,7 +125,7 @@ extern void cost_merge_append(Path *path, PlannerInfo *root,
 							  Cost input_startup_cost, Cost input_total_cost,
 							  double tuples);
 extern void cost_material(Path *path,
-						  int input_disabled_nodes,
+						  bool enabled, int input_disabled_nodes,
 						  Cost input_startup_cost, Cost input_total_cost,
 						  double tuples, int width);
 extern void cost_agg(Path *path, PlannerInfo *root,
@@ -148,7 +148,7 @@ extern void cost_group(Path *path, PlannerInfo *root,
 					   double input_tuples);
 extern void initial_cost_nestloop(PlannerInfo *root,
 								  JoinCostWorkspace *workspace,
-								  JoinType jointype,
+								  JoinType jointype, uint64 enable_mask,
 								  Path *outer_path, Path *inner_path,
 								  JoinPathExtraData *extra);
 extern void final_cost_nestloop(PlannerInfo *root, NestPath *path,
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 4437248cb67..274cd41bab1 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -17,6 +17,14 @@
 #include "nodes/bitmapset.h"
 #include "nodes/pathnodes.h"
 
+/* Hook for plugins to get control during joinrel setup */
+typedef void (*joinrel_setup_hook_type) (PlannerInfo *root,
+										 RelOptInfo *joinrel,
+										 RelOptInfo *outer_rel,
+										 RelOptInfo *inner_rel,
+										 SpecialJoinInfo *sjinfo,
+										 List *restrictlist);
+extern PGDLLIMPORT joinrel_setup_hook_type joinrel_setup_hook;
 
 /*
  * prototypes for pathnode.c
@@ -84,7 +92,8 @@ extern GroupResultPath *create_group_result_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 PathTarget *target,
 												 List *havingqual);
-extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath);
+extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath,
+										  bool enabled);
 extern MemoizePath *create_memoize_path(PlannerInfo *root,
 										RelOptInfo *rel,
 										Path *subpath,
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index f6a62df0b43..61c1607f872 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -28,7 +28,14 @@ extern PGDLLIMPORT int min_parallel_table_scan_size;
 extern PGDLLIMPORT int min_parallel_index_scan_size;
 extern PGDLLIMPORT bool enable_group_by_reordering;
 
-/* Hook for plugins to get control in set_rel_pathlist() */
+/* Hooks for plugins to get control in set_rel_pathlist() */
+typedef void (*join_path_setup_hook_type) (PlannerInfo *root,
+										   RelOptInfo *joinrel,
+										   RelOptInfo *outerrel,
+										   RelOptInfo *innerrel,
+										   JoinType jointype,
+										   JoinPathExtraData *extra);
+extern PGDLLIMPORT join_path_setup_hook_type join_path_setup_hook;
 typedef void (*set_rel_pathlist_hook_type) (PlannerInfo *root,
 											RelOptInfo *rel,
 											Index rti,
-- 
2.51.0



  [application/octet-stream] v2-0003-Store-information-about-Append-node-consolidation.patch (27.0K, 3-v2-0003-Store-information-about-Append-node-consolidation.patch)
  download | inline diff:
From faf1612c1278c6d1fd0aa6f17c90861a33aa2890 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:07 -0400
Subject: [PATCH v2 3/6] Store information about Append node consolidation in
 the final plan.

An extension (or core code) might want to reconstruct the planner's
decisions about whether and where to perform partitionwise joins from
the final plan. To do so, it must be possible to find all of the RTIs
of partitioned tables appearing in the plan. But when an AppendPath
or MergeAppendPath pulls up child paths from a subordinate AppendPath
or MergeAppendPath, the RTIs of the subordinate path do not appear
in the final plan, making this kind of reconstruction impossible.

To avoid this, propagate the RTI sets that would have been present
in the 'apprelids' field of the subordinate Append or MergeAppend
nodes that would have been created into the surviving Append or
MergeAppend node, using a new 'child_append_relid_sets' field for
that purpose. The value of this field is a list of Bitmapsets,
because each relation whose append-list was pulled up had its own
set of RTIs: just one, if it was a partitionwise scan, or more than
one, if it was a partitionwise join. Since our goal is to see where
partitionwise joins were done, it is essential to avoid losing the
information about how the RTIs were grouped in the pulled-up
relations.

This commit also updates pg_overexplain so that EXPLAIN (RANGE_TABLE)
will display the saved RTI sets.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 56 +++++++++++
 src/backend/optimizer/path/allpaths.c         | 98 +++++++++++++++----
 src/backend/optimizer/path/joinrels.c         |  2 +-
 src/backend/optimizer/plan/createplan.c       |  2 +
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/prep/prepunion.c        | 11 ++-
 src/backend/optimizer/util/pathnode.c         |  5 +
 src/include/nodes/pathnodes.h                 | 10 ++
 src/include/nodes/plannodes.h                 | 11 +++
 src/include/optimizer/pathnode.h              |  2 +
 11 files changed, 175 insertions(+), 27 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index ca9a23ea61f..a377fb2571d 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -104,6 +104,7 @@ $$);
                Parallel Safe: true
                Plan Node ID: 2
                Append RTIs: 1
+               Child Append RTIs: none
                ->  Seq Scan on brassica vegetables_1
                      Disabled Nodes: 0
                      Parallel Safe: true
@@ -142,7 +143,7 @@ $$);
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 3 4
-(53 rows)
+(54 rows)
 
 -- Test a different output format.
 SELECT explain_filter($$
@@ -197,6 +198,7 @@ $$);
                <extParam>none</extParam>                            +
                <allParam>none</allParam>                            +
                <Append-RTIs>1</Append-RTIs>                         +
+               <Child-Append-RTIs>none</Child-Append-RTIs>          +
                <Subplans-Removed>0</Subplans-Removed>               +
                <Plans>                                              +
                  <Plan>                                             +
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index fa907fa472e..6538ffcafb0 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -54,6 +54,8 @@ static void overexplain_alias(const char *qlabel, Alias *alias,
 							  ExplainState *es);
 static void overexplain_bitmapset(const char *qlabel, Bitmapset *bms,
 								  ExplainState *es);
+static void overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+									   ExplainState *es);
 static void overexplain_intlist(const char *qlabel, List *list,
 								ExplainState *es);
 
@@ -232,11 +234,17 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 				overexplain_bitmapset("Append RTIs",
 									  ((Append *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((Append *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_MergeAppend:
 				overexplain_bitmapset("Append RTIs",
 									  ((MergeAppend *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((MergeAppend *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_Result:
 
@@ -815,6 +823,54 @@ overexplain_bitmapset(const char *qlabel, Bitmapset *bms, ExplainState *es)
 	pfree(buf.data);
 }
 
+/*
+ * Emit a text property describing the contents of a list of bitmapsets.
+ * If a bitmapset contains exactly 1 member, we just print an integer;
+ * otherwise, we surround the list of members by parentheses.
+ *
+ * If there are no bitmapsets in the list, we print the word "none".
+ */
+static void
+overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+						   ExplainState *es)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+
+	foreach_node(Bitmapset, bms, bms_list)
+	{
+		if (bms_membership(bms) == BMS_SINGLETON)
+			appendStringInfo(&buf, " %d", bms_singleton_member(bms));
+		else
+		{
+			int			x = -1;
+			bool		first = true;
+
+			appendStringInfoString(&buf, " (");
+			while ((x = bms_next_member(bms, x)) >= 0)
+			{
+				if (first)
+					first = false;
+				else
+					appendStringInfoChar(&buf, ' ');
+				appendStringInfo(&buf, "%d", x);
+			}
+			appendStringInfoChar(&buf, ')');
+		}
+	}
+
+	if (buf.len == 0)
+	{
+		ExplainPropertyText(qlabel, "none", es);
+		return;
+	}
+
+	Assert(buf.data[0] == ' ');
+	ExplainPropertyText(qlabel, buf.data + 1, es);
+	pfree(buf.data);
+}
+
 /*
  * Emit a text property describing the contents of a list of integers, OIDs,
  * or XIDs -- either a space-separated list of integer members, or the word
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 9c6436eb72f..349863fb194 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -128,8 +128,10 @@ static Path *get_cheapest_parameterized_child_path(PlannerInfo *root,
 												   Relids required_outer);
 static void accumulate_append_subpath(Path *path,
 									  List **subpaths,
-									  List **special_subpaths);
-static Path *get_singleton_append_subpath(Path *path);
+									  List **special_subpaths,
+									  List **child_append_relid_sets);
+static Path *get_singleton_append_subpath(Path *path,
+										  List **child_append_relid_sets);
 static void set_dummy_rel_pathlist(RelOptInfo *rel);
 static void set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 								  Index rti, RangeTblEntry *rte);
@@ -1406,11 +1408,15 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 {
 	List	   *subpaths = NIL;
 	bool		subpaths_valid = true;
+	List	   *subpath_cars = NIL;
 	List	   *startup_subpaths = NIL;
 	bool		startup_subpaths_valid = true;
+	List	   *startup_subpath_cars = NIL;
 	List	   *partial_subpaths = NIL;
+	List	   *partial_subpath_cars = NIL;
 	List	   *pa_partial_subpaths = NIL;
 	List	   *pa_nonpartial_subpaths = NIL;
+	List	   *pa_subpath_cars = NIL;
 	bool		partial_subpaths_valid = true;
 	bool		pa_subpaths_valid;
 	List	   *all_child_pathkeys = NIL;
@@ -1443,7 +1449,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		if (childrel->pathlist != NIL &&
 			childrel->cheapest_total_path->param_info == NULL)
 			accumulate_append_subpath(childrel->cheapest_total_path,
-									  &subpaths, NULL);
+									  &subpaths, NULL, &subpath_cars);
 		else
 			subpaths_valid = false;
 
@@ -1472,7 +1478,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 			Assert(cheapest_path->param_info == NULL);
 			accumulate_append_subpath(cheapest_path,
 									  &startup_subpaths,
-									  NULL);
+									  NULL,
+									  &startup_subpath_cars);
 		}
 		else
 			startup_subpaths_valid = false;
@@ -1483,7 +1490,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		{
 			cheapest_partial_path = linitial(childrel->partial_pathlist);
 			accumulate_append_subpath(cheapest_partial_path,
-									  &partial_subpaths, NULL);
+									  &partial_subpaths, NULL,
+									  &partial_subpath_cars);
 		}
 		else
 			partial_subpaths_valid = false;
@@ -1512,7 +1520,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				Assert(cheapest_partial_path != NULL);
 				accumulate_append_subpath(cheapest_partial_path,
 										  &pa_partial_subpaths,
-										  &pa_nonpartial_subpaths);
+										  &pa_nonpartial_subpaths,
+										  &pa_subpath_cars);
 			}
 			else
 			{
@@ -1531,7 +1540,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				 */
 				accumulate_append_subpath(nppath,
 										  &pa_nonpartial_subpaths,
-										  NULL);
+										  NULL,
+										  &pa_subpath_cars);
 			}
 		}
 
@@ -1606,14 +1616,16 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 	 * if we have zero or one live subpath due to constraint exclusion.)
 	 */
 	if (subpaths_valid)
-		add_path(rel, (Path *) create_append_path(root, rel, subpaths, NIL,
+		add_path(rel, (Path *) create_append_path(root, rel, subpaths,
+												  NIL, subpath_cars,
 												  NIL, NULL, 0, false,
 												  -1));
 
 	/* build an AppendPath for the cheap startup paths, if valid */
 	if (startup_subpaths_valid)
 		add_path(rel, (Path *) create_append_path(root, rel, startup_subpaths,
-												  NIL, NIL, NULL, 0, false, -1));
+												  NIL, startup_subpath_cars,
+												  NIL, NULL, 0, false, -1));
 
 	/*
 	 * Consider an append of unordered, unparameterized partial paths.  Make
@@ -1654,6 +1666,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Generate a partial append path. */
 		appendpath = create_append_path(root, rel, NIL, partial_subpaths,
+										partial_subpath_cars,
 										NIL, NULL, parallel_workers,
 										enable_parallel_append,
 										-1);
@@ -1704,6 +1717,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		appendpath = create_append_path(root, rel, pa_nonpartial_subpaths,
 										pa_partial_subpaths,
+										pa_subpath_cars,
 										NIL, NULL, parallel_workers, true,
 										partial_rows);
 		add_partial_path(rel, (Path *) appendpath);
@@ -1737,6 +1751,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Select the child paths for an Append with this parameterization */
 		subpaths = NIL;
+		subpath_cars = NIL;
 		subpaths_valid = true;
 		foreach(lcr, live_childrels)
 		{
@@ -1759,12 +1774,13 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				subpaths_valid = false;
 				break;
 			}
-			accumulate_append_subpath(subpath, &subpaths, NULL);
+			accumulate_append_subpath(subpath, &subpaths, NULL,
+									  &subpath_cars);
 		}
 
 		if (subpaths_valid)
 			add_path(rel, (Path *)
-					 create_append_path(root, rel, subpaths, NIL,
+					 create_append_path(root, rel, subpaths, NIL, subpath_cars,
 										NIL, required_outer, 0, false,
 										-1));
 	}
@@ -1791,6 +1807,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				continue;
 
 			appendpath = create_append_path(root, rel, NIL, list_make1(path),
+											list_make1(rel->relids),
 											NIL, NULL,
 											path->parallel_workers, true,
 											partial_rows);
@@ -1872,8 +1889,11 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 	{
 		List	   *pathkeys = (List *) lfirst(lcp);
 		List	   *startup_subpaths = NIL;
+		List	   *startup_subpath_cars = NIL;
 		List	   *total_subpaths = NIL;
+		List	   *total_subpath_cars = NIL;
 		List	   *fractional_subpaths = NIL;
+		List	   *fractional_subpath_cars = NIL;
 		bool		startup_neq_total = false;
 		bool		match_partition_order;
 		bool		match_partition_order_desc;
@@ -2025,16 +2045,23 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * just a single subpath (and hence aren't doing anything
 				 * useful).
 				 */
-				cheapest_startup = get_singleton_append_subpath(cheapest_startup);
-				cheapest_total = get_singleton_append_subpath(cheapest_total);
+				cheapest_startup =
+					get_singleton_append_subpath(cheapest_startup,
+												 &startup_subpath_cars);
+				cheapest_total =
+					get_singleton_append_subpath(cheapest_total,
+												 &total_subpath_cars);
 
 				startup_subpaths = lappend(startup_subpaths, cheapest_startup);
 				total_subpaths = lappend(total_subpaths, cheapest_total);
 
 				if (cheapest_fractional)
 				{
-					cheapest_fractional = get_singleton_append_subpath(cheapest_fractional);
-					fractional_subpaths = lappend(fractional_subpaths, cheapest_fractional);
+					cheapest_fractional =
+						get_singleton_append_subpath(cheapest_fractional,
+													 &fractional_subpath_cars);
+					fractional_subpaths =
+						lappend(fractional_subpaths, cheapest_fractional);
 				}
 			}
 			else
@@ -2044,13 +2071,16 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * child paths for the MergeAppend.
 				 */
 				accumulate_append_subpath(cheapest_startup,
-										  &startup_subpaths, NULL);
+										  &startup_subpaths, NULL,
+										  &startup_subpath_cars);
 				accumulate_append_subpath(cheapest_total,
-										  &total_subpaths, NULL);
+										  &total_subpaths, NULL,
+										  &total_subpath_cars);
 
 				if (cheapest_fractional)
 					accumulate_append_subpath(cheapest_fractional,
-											  &fractional_subpaths, NULL);
+											  &fractional_subpaths, NULL,
+											  &fractional_subpath_cars);
 			}
 		}
 
@@ -2062,6 +2092,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 													  rel,
 													  startup_subpaths,
 													  NIL,
+													  startup_subpath_cars,
 													  pathkeys,
 													  NULL,
 													  0,
@@ -2072,6 +2103,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  total_subpaths,
 														  NIL,
+														  total_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2083,6 +2115,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  fractional_subpaths,
 														  NIL,
+														  fractional_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2095,12 +2128,14 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 			add_path(rel, (Path *) create_merge_append_path(root,
 															rel,
 															startup_subpaths,
+															startup_subpath_cars,
 															pathkeys,
 															NULL));
 			if (startup_neq_total)
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																total_subpaths,
+																total_subpath_cars,
 																pathkeys,
 																NULL));
 
@@ -2108,6 +2143,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																fractional_subpaths,
+																fractional_subpath_cars,
 																pathkeys,
 																NULL));
 		}
@@ -2210,7 +2246,8 @@ get_cheapest_parameterized_child_path(PlannerInfo *root, RelOptInfo *rel,
  * paths).
  */
 static void
-accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
+accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
+						  List **child_append_relid_sets)
 {
 	if (IsA(path, AppendPath))
 	{
@@ -2219,6 +2256,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		if (!apath->path.parallel_aware || apath->first_partial_path == 0)
 		{
 			*subpaths = list_concat(*subpaths, apath->subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 		else if (special_subpaths != NULL)
@@ -2233,6 +2272,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 												  apath->first_partial_path);
 			*special_subpaths = list_concat(*special_subpaths,
 											new_special_subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 	}
@@ -2241,6 +2282,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		*subpaths = list_concat(*subpaths, mpath->subpaths);
+		*child_append_relid_sets =
+			lappend(*child_append_relid_sets, path->parent->relids);
 		return;
 	}
 
@@ -2252,10 +2295,15 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
  *		Returns the single subpath of an Append/MergeAppend, or just
  *		return 'path' if it's not a single sub-path Append/MergeAppend.
  *
+ * As a side effect, whenever we return a single subpath rather than the
+ * original path, add the relid set for the original path to
+ * child_append_relid_sets, so that those relids don't entirely disappear
+ * from the final plan.
+ *
  * Note: 'path' must not be a parallel-aware path.
  */
 static Path *
-get_singleton_append_subpath(Path *path)
+get_singleton_append_subpath(Path *path, List **child_append_relid_sets)
 {
 	Assert(!path->parallel_aware);
 
@@ -2264,14 +2312,22 @@ get_singleton_append_subpath(Path *path)
 		AppendPath *apath = (AppendPath *) path;
 
 		if (list_length(apath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(apath->subpaths);
+		}
 	}
 	else if (IsA(path, MergeAppendPath))
 	{
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		if (list_length(mpath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(mpath->subpaths);
+		}
 	}
 
 	return path;
@@ -2300,7 +2356,7 @@ set_dummy_rel_pathlist(RelOptInfo *rel)
 	rel->partial_pathlist = NIL;
 
 	/* Set up the dummy path */
-	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
+	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL, NIL,
 											  NIL, rel->lateral_relids,
 											  0, false, -1));
 
diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 5d1fc3899da..c1ed0d3870f 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -1530,7 +1530,7 @@ mark_dummy_rel(RelOptInfo *rel)
 
 	/* Set up the dummy path */
 	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
-											  NIL, rel->lateral_relids,
+											  NIL, NIL, rel->lateral_relids,
 											  0, false, -1));
 
 	/* Set or update cheapest_total_path and related fields */
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 8af091ba647..88b4c5901b0 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -1265,6 +1265,7 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags)
 	plan->plan.lefttree = NULL;
 	plan->plan.righttree = NULL;
 	plan->apprelids = rel->relids;
+	plan->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	if (pathkeys != NIL)
 	{
@@ -1477,6 +1478,7 @@ create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path,
 	plan->lefttree = NULL;
 	plan->righttree = NULL;
 	node->apprelids = rel->relids;
+	node->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	/*
 	 * Compute sort column info, and adjust MergeAppend's tlist as needed.
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 9d5262651e7..eb62794aecd 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4027,6 +4027,7 @@ create_degenerate_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
 							   paths,
 							   NIL,
 							   NIL,
+							   NIL,
 							   NULL,
 							   0,
 							   false,
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index 55665824179..b7c4c0686d0 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -839,7 +839,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 	 * union child.
 	 */
 	apath = (Path *) create_append_path(root, result_rel, cheapest_pathlist,
-										NIL, NIL, NULL, 0, false, -1);
+										NIL, NIL, NIL, NULL, 0, false, -1);
 
 	/*
 	 * Estimate number of groups.  For now we just assume the output is unique
@@ -885,7 +885,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 
 		papath = (Path *)
 			create_append_path(root, result_rel, NIL, partial_pathlist,
-							   NIL, NULL, parallel_workers,
+							   NIL, NIL, NULL, parallel_workers,
 							   enable_parallel_append, -1);
 		gpath = (Path *)
 			create_gather_path(root, result_rel, papath,
@@ -1011,6 +1011,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 			path = (Path *) create_merge_append_path(root,
 													 result_rel,
 													 ordered_pathlist,
+													 NIL,
 													 union_pathkeys,
 													 NULL);
 
@@ -1217,8 +1218,10 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root,
 				 * between the set op targetlist and the targetlist of the
 				 * left input.  The Append will be removed in setrefs.c.
 				 */
-				apath = (Path *) create_append_path(root, result_rel, list_make1(lpath),
-													NIL, NIL, NULL, 0, false, -1);
+				apath = (Path *) create_append_path(root, result_rel,
+													list_make1(lpath),
+													NIL, NIL, NIL, NULL, 0,
+													false, -1);
 
 				add_path(result_rel, apath);
 
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index e4fd6950fad..c0a9811b130 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1300,6 +1300,7 @@ AppendPath *
 create_append_path(PlannerInfo *root,
 				   RelOptInfo *rel,
 				   List *subpaths, List *partial_subpaths,
+				   List *child_append_relid_sets,
 				   List *pathkeys, Relids required_outer,
 				   int parallel_workers, bool parallel_aware,
 				   double rows)
@@ -1309,6 +1310,7 @@ create_append_path(PlannerInfo *root,
 
 	Assert(!parallel_aware || parallel_workers > 0);
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_Append;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -1471,6 +1473,7 @@ MergeAppendPath *
 create_merge_append_path(PlannerInfo *root,
 						 RelOptInfo *rel,
 						 List *subpaths,
+						 List *child_append_relid_sets,
 						 List *pathkeys,
 						 Relids required_outer)
 {
@@ -1486,6 +1489,7 @@ create_merge_append_path(PlannerInfo *root,
 	 */
 	Assert(bms_is_empty(rel->lateral_relids) && bms_is_empty(required_outer));
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_MergeAppend;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -3950,6 +3954,7 @@ reparameterize_path(PlannerInfo *root, Path *path,
 				}
 				return (Path *)
 					create_append_path(root, rel, childpaths, partialpaths,
+									   apath->child_append_relid_sets,
 									   apath->path.pathkeys, required_outer,
 									   apath->path.parallel_workers,
 									   apath->path.parallel_aware,
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index cf3a16b8b0e..75a70489e5a 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2171,6 +2171,12 @@ typedef struct CustomPath
  * For partial Append, 'subpaths' contains non-partial subpaths followed by
  * partial subpaths.
  *
+ * Whenever accumulate_append_subpath() allows us to consolidate multiple
+ * levels of Append paths are consolidated down to one, we store the RTI
+ * sets for the omitted paths in child_append_relid_sets. This is not necessary
+ * for planning or execution; we do it for the benefit of code that wants
+ * to inspect the final plan and understand how it came to be.
+ *
  * Note: it is possible for "subpaths" to contain only one, or even no,
  * elements.  These cases are optimized during create_append_plan.
  * In particular, an AppendPath with no subpaths is a "dummy" path that
@@ -2186,6 +2192,7 @@ typedef struct AppendPath
 	/* Index of first partial path in subpaths; list_length(subpaths) if none */
 	int			first_partial_path;
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } AppendPath;
 
 #define IS_DUMMY_APPEND(p) \
@@ -2202,12 +2209,15 @@ extern bool is_dummy_rel(RelOptInfo *rel);
 /*
  * MergeAppendPath represents a MergeAppend plan, ie, the merging of sorted
  * results from several member plans to produce similarly-sorted output.
+ *
+ * child_append_relid_sets has the same meaning here as for AppendPath.
  */
 typedef struct MergeAppendPath
 {
 	Path		path;
 	List	   *subpaths;		/* list of component Paths */
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } MergeAppendPath;
 
 /*
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 5d0520d5e58..045b7ee84a7 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -394,9 +394,16 @@ struct PartitionPruneInfo;		/* forward reference to struct below */
 typedef struct Append
 {
 	Plan		plan;
+
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
+
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *appendplans;
+
 	/* # of asynchronous plans */
 	int			nasyncplans;
 
@@ -426,6 +433,10 @@ typedef struct MergeAppend
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
 
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *mergeplans;
 
 	/* these fields are just like the sort-key info in struct Sort: */
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 955e9056858..4437248cb67 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -70,12 +70,14 @@ extern TidRangePath *create_tidrangescan_path(PlannerInfo *root,
 											  Relids required_outer);
 extern AppendPath *create_append_path(PlannerInfo *root, RelOptInfo *rel,
 									  List *subpaths, List *partial_subpaths,
+									  List *child_append_relid_sets,
 									  List *pathkeys, Relids required_outer,
 									  int parallel_workers, bool parallel_aware,
 									  double rows);
 extern MergeAppendPath *create_merge_append_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 List *subpaths,
+												 List *child_append_relid_sets,
 												 List *pathkeys,
 												 Relids required_outer);
 extern GroupResultPath *create_group_result_path(PlannerInfo *root,
-- 
2.51.0



  [application/octet-stream] v2-0002-Store-information-about-elided-nodes-in-the-final.patch (9.3K, 4-v2-0002-Store-information-about-elided-nodes-in-the-final.patch)
  download | inline diff:
From 9aee8d32d8077c11a9398833b19ff9d3e88769a0 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:42 -0400
Subject: [PATCH v2 2/6] Store information about elided nodes in the final
 plan.

An extension (or core code) might want to reconstruct the planner's
choice of join order from the final plan. To do so, it must be possible
to find all of the RTIs that were part of the join problem in that plan.
The previous commit, together with the earlier work in
8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0, is enough to let us match up
RTIs we see in the final plan with RTIs that we see during the planning
cycle, but we still have a problem if the planner decides to drop some
RTIs out of the final plan altogether.

To fix that, when setrefs.c removes a SubqueryScan, single-child Append,
or single-child MergeAppend from the final Plan tree, record the type of
the removed node and the RTIs that the removed node would have scanned
in the final plan tree. It would be natural to record this information
on the child of the removed plan node, but that would require adding
an additional pointer field to type Plan, which seems undesirable.
So, instead, store the information in a separate list that the
executor need never consult, and use the plan_node_id to identify
the plan node with which the removed node is logically associated.

Also, update pg_overexplain to display these details.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 39 ++++++++++++++
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/plan/setrefs.c          | 52 ++++++++++++++++++-
 src/include/nodes/pathnodes.h                 |  3 ++
 src/include/nodes/plannodes.h                 | 17 ++++++
 src/tools/pgindent/typedefs.list              |  1 +
 7 files changed, 114 insertions(+), 3 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 55d34666d87..ca9a23ea61f 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -452,6 +452,8 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
  Seq Scan on daucus vegetables
    Filter: (genus = 'daucus'::text)
    Scan RTI: 2
+   Elided Node Type: Append
+   Elided Node RTIs: 1
  RTI 1 (relation, inherited, in-from-clause):
    Eref: vegetables (id, name, genus)
    Relation: vegetables
@@ -465,7 +467,7 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 2
-(16 rows)
+(18 rows)
 
 -- Also test a case that involves a write.
 EXPLAIN (RANGE_TABLE, COSTS OFF)
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index 5dc707d69e3..fa907fa472e 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -191,6 +191,8 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 	 */
 	if (options->range_table)
 	{
+		bool		opened_elided_nodes = false;
+
 		switch (nodeTag(plan))
 		{
 			case T_SeqScan:
@@ -251,6 +253,43 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 			default:
 				break;
 		}
+
+		foreach_node(ElidedNode, n, es->pstmt->elidedNodes)
+		{
+			char	   *elidednodetag;
+
+			if (n->plan_node_id != plan->plan_node_id)
+				continue;
+
+			if (!opened_elided_nodes)
+			{
+				ExplainOpenGroup("Elided Nodes", "Elided Nodes", false, es);
+				opened_elided_nodes = true;
+			}
+
+			switch (n->elided_type)
+			{
+				case T_Append:
+					elidednodetag = "Append";
+					break;
+				case T_MergeAppend:
+					elidednodetag = "MergeAppend";
+					break;
+				case T_SubqueryScan:
+					elidednodetag = "SubqueryScan";
+					break;
+				default:
+					elidednodetag = psprintf("%d", n->elided_type);
+					break;
+			}
+
+			ExplainOpenGroup("Elided Node", NULL, true, es);
+			ExplainPropertyText("Elided Node Type", elidednodetag, es);
+			overexplain_bitmapset("Elided Node RTIs", n->relids, es);
+			ExplainCloseGroup("Elided Node", NULL, true, es);
+		}
+		if (opened_elided_nodes)
+			ExplainCloseGroup("Elided Nodes", "Elided Nodes", false, es);
 	}
 }
 
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 0e6b3f60f31..9d5262651e7 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -618,6 +618,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->paramExecTypes = glob->paramExecTypes;
 	/* utilityStmt should be null, but we might as well copy it */
 	result->utilityStmt = parse->utilityStmt;
+	result->elidedNodes = glob->elidedNodes;
 	result->stmt_location = parse->stmt_location;
 	result->stmt_len = parse->stmt_len;
 
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index adabae09a23..23a00d452b7 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -211,6 +211,9 @@ static List *set_windowagg_runcondition_references(PlannerInfo *root,
 												   List *runcondition,
 												   Plan *plan);
 
+static void record_elided_node(PlannerGlobal *glob, int plan_node_id,
+							   NodeTag elided_type, Bitmapset *relids);
+
 
 /*****************************************************************************
  *
@@ -1460,10 +1463,17 @@ set_subqueryscan_references(PlannerInfo *root,
 
 	if (trivial_subqueryscan(plan))
 	{
+		Index		scanrelid;
+
 		/*
 		 * We can omit the SubqueryScan node and just pull up the subplan.
 		 */
 		result = clean_up_removed_plan_level((Plan *) plan, plan->subplan);
+
+		/* Remember that we removed a SubqueryScan */
+		scanrelid = plan->scan.scanrelid + rtoffset;
+		record_elided_node(root->glob, plan->subplan->plan_node_id,
+						   T_SubqueryScan, bms_make_singleton(scanrelid));
 	}
 	else
 	{
@@ -1891,7 +1901,17 @@ set_append_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(aplan->appendplans);
 
 		if (p->parallel_aware == aplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) aplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) aplan, p);
+
+			/* Remember that we removed an Append */
+			record_elided_node(root->glob, p->plan_node_id, T_Append,
+							   offset_relid_set(aplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -1959,7 +1979,17 @@ set_mergeappend_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(mplan->mergeplans);
 
 		if (p->parallel_aware == mplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) mplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) mplan, p);
+
+			/* Remember that we removed a MergeAppend */
+			record_elided_node(root->glob, p->plan_node_id, T_MergeAppend,
+							   offset_relid_set(mplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -3774,3 +3804,21 @@ extract_query_dependencies_walker(Node *node, PlannerInfo *context)
 	return expression_tree_walker(node, extract_query_dependencies_walker,
 								  context);
 }
+
+/*
+ * Record some details about a node removed from the plan during setrefs
+ * procesing, for the benefit of code trying to reconstruct planner decisions
+ * from examination of the final plan tree.
+ */
+static void
+record_elided_node(PlannerGlobal *glob, int plan_node_id,
+				   NodeTag elided_type, Bitmapset *relids)
+{
+	ElidedNode *n = makeNode(ElidedNode);
+
+	n->plan_node_id = plan_node_id;
+	n->elided_type = elided_type;
+	n->relids = relids;
+
+	glob->elidedNodes = lappend(glob->elidedNodes, n);
+}
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index a3a800869df..cf3a16b8b0e 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -159,6 +159,9 @@ typedef struct PlannerGlobal
 	/* type OIDs for PARAM_EXEC Params */
 	List	   *paramExecTypes;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/* highest PlaceHolderVar ID assigned */
 	Index		lastPHId;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 1526dd2ec6b..5d0520d5e58 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -152,6 +152,9 @@ typedef struct PlannedStmt
 	/* non-null if this is utility stmt */
 	Node	   *utilityStmt;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/*
 	 * DefElem objects added by extensions, e.g. using planner_shutdown_hook
 	 *
@@ -1838,4 +1841,18 @@ typedef struct SubPlanRTInfo
 	bool		dummy;
 } SubPlanRTInfo;
 
+/*
+ * ElidedNode
+ *
+ * Information about nodes elided from the final plan tree: trivial subquery
+ * scans, and single-child Append and MergeAppend nodes.
+ */
+typedef struct ElidedNode
+{
+	NodeTag		type;
+	int			plan_node_id;
+	NodeTag		elided_type;
+	Bitmapset  *relids;
+} ElidedNode;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 4f81fb7df2d..bfe9ff0d92c 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -697,6 +697,7 @@ EachState
 Edge
 EditableObjectType
 ElementsState
+ElidedNode
 EnableTimeoutParams
 EndDataPtrType
 EndDirectModify_function
-- 
2.51.0



  [application/octet-stream] v2-0001-Store-information-about-range-table-flattening-in.patch (7.9K, 5-v2-0001-Store-information-about-range-table-flattening-in.patch)
  download | inline diff:
From fbf3a7a5f5f6ccdc255c58690afece8f0f449293 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 12:00:18 -0400
Subject: [PATCH v2 1/6] Store information about range-table flattening in the
 final plan.

Suppose that we're currently planning a query and, when that same
query was previously planned and executed, we learned something about
how a certain table within that query should be planned. We want to
take note when that same table is being planned during the current
planning cycle, but this is difficult to do, because the RTI of the
table from the previous plan won't necessarily be equal to the RTI
that we see during the current planning cycle. This is because each
subquery has a separate range table during planning, but these are
flattened into one range table when constructing the final plan,
changing RTIs.

Commit 8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0 allows us to match up
subqueries seen in the previous planning cycles with the subqueries
currently being planned just by comparing textual names, but that's
not quite enough to let us deduce anything about individual tables,
because we don't know where each subquery's range table appears in
the final, flattened range table.

To fix that, store a list of SubPlanRTInfo objects in the final
planned statement, each including the name of the subplan, the offset
at which it begins in the flattened range table, and whether or not
it was a dummy subplan -- if it was, some RTIs may have been dropped
from the final range table, but also there's no need to control how
a dummy subquery gets planned. The toplevel subquery has no name and
always begins at rtoffset 0, so we make no entry for it.

This commit teaches pg_overexplain'e RANGE_TABLE option to make use
of this new data to display the subquery name for each range table
entry.

NOTE TO REVIEWERS: If there's a clean way to make pg_overexplain display
this information without the new infrastructure provided by this patch,
then this patch is unnecessary. I thought there would be a way to do
that, but I couldn't figure anything out: there seems to be nothing that
records in the final PlannedStmt where subquery's range table ends and
the next one begins. In practice, one could usually figure it out by
matching up tables by relation OID, but that's neither clean nor
theoretically sound.
---
 contrib/pg_overexplain/pg_overexplain.c | 36 +++++++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    |  1 +
 src/backend/optimizer/plan/setrefs.c    | 20 ++++++++++++++
 src/include/nodes/pathnodes.h           |  3 +++
 src/include/nodes/plannodes.h           | 17 ++++++++++++
 src/tools/pgindent/typedefs.list        |  1 +
 6 files changed, 78 insertions(+)

diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index bd70b6d9d5e..5dc707d69e3 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -395,6 +395,8 @@ static void
 overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 {
 	Index		rti;
+	ListCell   *lc_subrtinfo = list_head(plannedstmt->subrtinfos);
+	SubPlanRTInfo *rtinfo = NULL;
 
 	/* Open group, one entry per RangeTblEntry */
 	ExplainOpenGroup("Range Table", "Range Table", false, es);
@@ -405,6 +407,18 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 		RangeTblEntry *rte = rt_fetch(rti, plannedstmt->rtable);
 		char	   *kind = NULL;
 		char	   *relkind;
+		SubPlanRTInfo *next_rtinfo;
+
+		/* Advance to next SubRTInfo, if it's time. */
+		if (lc_subrtinfo != NULL)
+		{
+			next_rtinfo = lfirst(lc_subrtinfo);
+			if (rti > next_rtinfo->rtoffset)
+			{
+				rtinfo = next_rtinfo;
+				lc_subrtinfo = lnext(plannedstmt->subrtinfos, lc_subrtinfo);
+			}
+		}
 
 		/* NULL entries are possible; skip them */
 		if (rte == NULL)
@@ -469,6 +483,28 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 			ExplainPropertyBool("In From Clause", rte->inFromCl, es);
 		}
 
+		/*
+		 * Indicate which subplan is the origin of which RTE. Note dummy
+		 * subplans. Here again, we crunch more onto one line in text format.
+		 */
+		if (rtinfo != NULL)
+		{
+			if (es->format == EXPLAIN_FORMAT_TEXT)
+			{
+				if (!rtinfo->dummy)
+					ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				else
+					ExplainPropertyText("Subplan",
+										psprintf("%s (dummy)",
+												 rtinfo->plan_name), es);
+			}
+			else
+			{
+				ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				ExplainPropertyBool("Subplan Is Dummy", rtinfo->dummy, es);
+			}
+		}
+
 		/* rte->alias is optional; rte->eref is requested */
 		if (rte->alias != NULL)
 			overexplain_alias("Alias", rte->alias, es);
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index c4fd646b999..0e6b3f60f31 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -607,6 +607,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->unprunableRelids = bms_difference(glob->allRelids,
 											  glob->prunableRelids);
 	result->permInfos = glob->finalrteperminfos;
+	result->subrtinfos = glob->subrtinfos;
 	result->resultRelations = glob->resultRelations;
 	result->appendRelations = glob->appendRelations;
 	result->subplans = glob->subplans;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ccdc9bc264a..adabae09a23 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -399,6 +399,26 @@ add_rtes_to_flat_rtable(PlannerInfo *root, bool recursing)
 	Index		rti;
 	ListCell   *lc;
 
+	/*
+	 * Record enough information to make it possible for code that looks at
+	 * the final range table to understand how it was constructed. (If
+	 * finalrtable is still NIL, then this is the very topmost PlannerInfo,
+	 * which will always have plan_name == NULL and rtoffset == 0; we omit the
+	 * degenerate list entry.)
+	 */
+	if (root->glob->finalrtable != NIL)
+	{
+		SubPlanRTInfo *rtinfo = makeNode(SubPlanRTInfo);
+
+		rtinfo->plan_name = root->plan_name;
+		rtinfo->rtoffset = list_length(root->glob->finalrtable);
+
+		/* When recursing = true, it's an unplanned or dummy subquery. */
+		rtinfo->dummy = recursing;
+
+		root->glob->subrtinfos = lappend(root->glob->subrtinfos, rtinfo);
+	}
+
 	/*
 	 * Add the query's own RTEs to the flattened rangetable.
 	 *
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 30d889b54c5..a3a800869df 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -135,6 +135,9 @@ typedef struct PlannerGlobal
 	/* "flat" list of RTEPermissionInfos */
 	List	   *finalrteperminfos;
 
+	/* list of SubPlanRTInfo nodes */
+	List	   *subrtinfos;
+
 	/* "flat" list of PlanRowMarks */
 	List	   *finalrowmarks;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index c4393a94321..1526dd2ec6b 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -131,6 +131,9 @@ typedef struct PlannedStmt
 	 */
 	List	   *subplans;
 
+	/* a list of SubPlanRTInfo objects */
+	List	   *subrtinfos;
+
 	/* indices of subplans that require REWIND */
 	Bitmapset  *rewindPlanIDs;
 
@@ -1821,4 +1824,18 @@ typedef enum MonotonicFunction
 	MONOTONICFUNC_BOTH = MONOTONICFUNC_INCREASING | MONOTONICFUNC_DECREASING,
 } MonotonicFunction;
 
+/*
+ * SubPlanRTInfo
+ *
+ * Information about which range table entries came from which subquery
+ * planning cycles.
+ */
+typedef struct SubPlanRTInfo
+{
+	NodeTag		type;
+	const char *plan_name;
+	Index		rtoffset;
+	bool		dummy;
+} SubPlanRTInfo;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 018b5919cf6..4f81fb7df2d 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2887,6 +2887,7 @@ SubLink
 SubLinkType
 SubOpts
 SubPlan
+SubPlanRTInfo
 SubPlanState
 SubRelInfo
 SubRemoveRels
-- 
2.51.0



  [application/octet-stream] v2-0004-Temporary-hack-to-unbreak-partitionwise-join-cont.patch (15.2K, 6-v2-0004-Temporary-hack-to-unbreak-partitionwise-join-cont.patch)
  download | inline diff:
From a3cca681e63f8620a72152dab81c4ad2fe01f84e Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Wed, 29 Oct 2025 15:17:46 -0400
Subject: [PATCH v2 4/6] Temporary hack to unbreak partitionwise join control.

Resetting the pathlist and partial pathlist to NIL when the
topmost scan/join rel is a partitioned joinrel is incorrect. The issue
was originally reported by Ashutosh Bapat here:

http://postgr.es/m/CAExHW5toze58+jL-454J3ty11sqJyU13Sz5rJPQZDmASwZgWiA@mail.gmail.com

I failed to understand Ashutosh's explanation until I hit the problem
myself, so here's my attempt to re-explain what he had said, just in
case you find my explanation any clearer:

http://postgr.es/m/CA%2BTgmoZvBD%2B5vyQruXBVXW74FMgWxE%3DO4K4rCrCtEELWNj-MLA%40mail.gmail.com

As subsequent discussion on that thread indicates, it is unclear
exactly what the right fix for this problem is, and at least as of
this writing, it is even more unclear how to adjust the test cases
that break. What I've done here is just accept all the changes to the
regression test outputs, which is almost certainly the wrong idea,
especially since I've also added no comments.

This is just a temporary hack to make it possible to test this patch
set, because without this, PARTITIONWISE() advice can't be used to
suppress a partitionwise join, because all of the alternatives get
eliminated regardless of cost.
---
 src/backend/optimizer/plan/planner.c         |   4 +-
 src/test/regress/expected/partition_join.out | 172 ++++++++-----------
 src/test/regress/expected/subselect.out      |  41 ++---
 3 files changed, 91 insertions(+), 126 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index eb62794aecd..8b1ab847f39 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -7927,7 +7927,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	 * generate_useful_gather_paths to add path(s) to the main list, and
 	 * finally zap the partial pathlist.
 	 */
-	if (rel_is_partitioned)
+	if (rel_is_partitioned && IS_SIMPLE_REL(rel))
 		rel->pathlist = NIL;
 
 	/*
@@ -7953,7 +7953,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	}
 
 	/* Finish dropping old paths for a partitioned rel, per comment above */
-	if (rel_is_partitioned)
+	if (rel_is_partitioned && IS_SIMPLE_REL(rel))
 		rel->partial_pathlist = NIL;
 
 	/* Extract SRF-free scan/join target. */
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index 713828be335..3e34f05ba62 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -65,31 +65,24 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
 -- inner join with partially-redundant join clauses
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
-                          QUERY PLAN                           
----------------------------------------------------------------
- Sort
-   Sort Key: t1.a
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Merge Join
+   Merge Cond: (t1.a = t2.a)
    ->  Append
-         ->  Merge Join
-               Merge Cond: (t1_1.a = t2_1.a)
-               ->  Index Scan using iprt1_p1_a on prt1_p1 t1_1
-               ->  Sort
-                     Sort Key: t2_1.b
-                     ->  Seq Scan on prt2_p1 t2_1
-                           Filter: (a = b)
-         ->  Hash Join
-               Hash Cond: (t1_2.a = t2_2.a)
-               ->  Seq Scan on prt1_p2 t1_2
-               ->  Hash
-                     ->  Seq Scan on prt2_p2 t2_2
-                           Filter: (a = b)
-         ->  Hash Join
-               Hash Cond: (t1_3.a = t2_3.a)
-               ->  Seq Scan on prt1_p3 t1_3
-               ->  Hash
-                     ->  Seq Scan on prt2_p3 t2_3
-                           Filter: (a = b)
-(22 rows)
+         ->  Index Scan using iprt1_p1_a on prt1_p1 t1_1
+         ->  Index Scan using iprt1_p2_a on prt1_p2 t1_2
+         ->  Index Scan using iprt1_p3_a on prt1_p3 t1_3
+   ->  Sort
+         Sort Key: t2.b
+         ->  Append
+               ->  Seq Scan on prt2_p1 t2_1
+                     Filter: (a = b)
+               ->  Seq Scan on prt2_p2 t2_2
+                     Filter: (a = b)
+               ->  Seq Scan on prt2_p3 t2_3
+                     Filter: (a = b)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
  a  |  c   | b  |  c   
@@ -1249,56 +1242,50 @@ SET enable_hashjoin TO off;
 SET enable_nestloop TO off;
 EXPLAIN (COSTS OFF)
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
-                            QUERY PLAN                            
-------------------------------------------------------------------
- Merge Append
-   Sort Key: t1.a
-   ->  Merge Semi Join
-         Merge Cond: (t1_3.a = t1_6.b)
-         ->  Sort
-               Sort Key: t1_3.a
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Merge Join
+   Merge Cond: (t1.a = t1_1.b)
+   ->  Sort
+         Sort Key: t1.a
+         ->  Append
                ->  Seq Scan on prt1_p1 t1_3
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_6.b = (((t1_9.a + t1_9.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_6.b
-                     ->  Seq Scan on prt2_p1 t1_6
-               ->  Sort
-                     Sort Key: (((t1_9.a + t1_9.b) / 2))
-                     ->  Seq Scan on prt1_e_p1 t1_9
-                           Filter: (c = 0)
-   ->  Merge Semi Join
-         Merge Cond: (t1_4.a = t1_7.b)
-         ->  Sort
-               Sort Key: t1_4.a
                ->  Seq Scan on prt1_p2 t1_4
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_7.b = (((t1_10.a + t1_10.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_7.b
-                     ->  Seq Scan on prt2_p2 t1_7
-               ->  Sort
-                     Sort Key: (((t1_10.a + t1_10.b) / 2))
-                     ->  Seq Scan on prt1_e_p2 t1_10
-                           Filter: (c = 0)
-   ->  Merge Semi Join
-         Merge Cond: (t1_5.a = t1_8.b)
-         ->  Sort
-               Sort Key: t1_5.a
                ->  Seq Scan on prt1_p3 t1_5
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_8.b = (((t1_11.a + t1_11.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_8.b
-                     ->  Seq Scan on prt2_p3 t1_8
-               ->  Sort
-                     Sort Key: (((t1_11.a + t1_11.b) / 2))
-                     ->  Seq Scan on prt1_e_p3 t1_11
-                           Filter: (c = 0)
-(47 rows)
+   ->  Unique
+         ->  Merge Append
+               Sort Key: t1_1.b
+               ->  Merge Semi Join
+                     Merge Cond: (t1_6.b = (((t1_9.a + t1_9.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_6.b
+                           ->  Seq Scan on prt2_p1 t1_6
+                     ->  Sort
+                           Sort Key: (((t1_9.a + t1_9.b) / 2))
+                           ->  Seq Scan on prt1_e_p1 t1_9
+                                 Filter: (c = 0)
+               ->  Merge Semi Join
+                     Merge Cond: (t1_7.b = (((t1_10.a + t1_10.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_7.b
+                           ->  Seq Scan on prt2_p2 t1_7
+                     ->  Sort
+                           Sort Key: (((t1_10.a + t1_10.b) / 2))
+                           ->  Seq Scan on prt1_e_p2 t1_10
+                                 Filter: (c = 0)
+               ->  Merge Semi Join
+                     Merge Cond: (t1_8.b = (((t1_11.a + t1_11.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_8.b
+                           ->  Seq Scan on prt2_p3 t1_8
+                     ->  Sort
+                           Sort Key: (((t1_11.a + t1_11.b) / 2))
+                           ->  Seq Scan on prt1_e_p3 t1_11
+                                 Filter: (c = 0)
+(41 rows)
 
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -4923,32 +4910,27 @@ ANALYZE plt3_adv;
 -- '0001' of that partition
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM (plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.c = t2.c)) FULL JOIN plt3_adv t3 ON (t1.c = t3.c) WHERE coalesce(t1.a, 0) % 5 != 3 AND coalesce(t1.a, 0) % 5 != 4 ORDER BY t1.c, t1.a, t2.a, t3.a;
-                                          QUERY PLAN                                           
------------------------------------------------------------------------------------------------
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
  Sort
    Sort Key: t1.c, t1.a, t2.a, t3.a
-   ->  Append
-         ->  Hash Full Join
-               Hash Cond: (t1_1.c = t3_1.c)
-               Filter: (((COALESCE(t1_1.a, 0) % 5) <> 3) AND ((COALESCE(t1_1.a, 0) % 5) <> 4))
-               ->  Hash Left Join
-                     Hash Cond: (t1_1.c = t2_1.c)
+   ->  Hash Full Join
+         Hash Cond: (t1.c = t3.c)
+         Filter: (((COALESCE(t1.a, 0) % 5) <> 3) AND ((COALESCE(t1.a, 0) % 5) <> 4))
+         ->  Hash Left Join
+               Hash Cond: (t1.c = t2.c)
+               ->  Append
                      ->  Seq Scan on plt1_adv_p1 t1_1
-                     ->  Hash
-                           ->  Seq Scan on plt2_adv_p1 t2_1
-               ->  Hash
-                     ->  Seq Scan on plt3_adv_p1 t3_1
-         ->  Hash Full Join
-               Hash Cond: (t1_2.c = t3_2.c)
-               Filter: (((COALESCE(t1_2.a, 0) % 5) <> 3) AND ((COALESCE(t1_2.a, 0) % 5) <> 4))
-               ->  Hash Left Join
-                     Hash Cond: (t1_2.c = t2_2.c)
                      ->  Seq Scan on plt1_adv_p2 t1_2
-                     ->  Hash
-                           ->  Seq Scan on plt2_adv_p2 t2_2
                ->  Hash
+                     ->  Append
+                           ->  Seq Scan on plt2_adv_p1 t2_1
+                           ->  Seq Scan on plt2_adv_p2 t2_2
+         ->  Hash
+               ->  Append
+                     ->  Seq Scan on plt3_adv_p1 t3_1
                      ->  Seq Scan on plt3_adv_p2 t3_2
-(23 rows)
+(18 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM (plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.c = t2.c)) FULL JOIN plt3_adv t3 ON (t1.c = t3.c) WHERE coalesce(t1.a, 0) % 5 != 3 AND coalesce(t1.a, 0) % 5 != 4 ORDER BY t1.c, t1.a, t2.a, t3.a;
  a  |  c   | a  |  c   | a  |  c   
@@ -5240,17 +5222,15 @@ SELECT x.id, y.id FROM fract_t x LEFT JOIN fract_t y USING (id) ORDER BY x.id AS
                               QUERY PLAN                               
 -----------------------------------------------------------------------
  Limit
-   ->  Merge Append
-         Sort Key: x.id
-         ->  Merge Left Join
-               Merge Cond: (x_1.id = y_1.id)
+   ->  Merge Left Join
+         Merge Cond: (x.id = y.id)
+         ->  Append
                ->  Index Only Scan using fract_t0_pkey on fract_t0 x_1
-               ->  Index Only Scan using fract_t0_pkey on fract_t0 y_1
-         ->  Merge Left Join
-               Merge Cond: (x_2.id = y_2.id)
                ->  Index Only Scan using fract_t1_pkey on fract_t1 x_2
+         ->  Append
+               ->  Index Only Scan using fract_t0_pkey on fract_t0 y_1
                ->  Index Only Scan using fract_t1_pkey on fract_t1 y_2
-(11 rows)
+(9 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT x.id, y.id FROM fract_t x LEFT JOIN fract_t y USING (id) ORDER BY x.id DESC LIMIT 10;
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index cf6b32d1173..8549601e3bc 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -850,10 +850,11 @@ where (t1.a, t2.a) in (select a, a from unique_tbl_p t3)
 order by t1.a, t2.a;
                                            QUERY PLAN                                           
 ------------------------------------------------------------------------------------------------
- Merge Append
-   Sort Key: t1.a
-   ->  Nested Loop
-         Output: t1_1.a, t1_1.b, t2_1.a, t2_1.b
+ Merge Join
+   Output: t1.a, t1.b, t2.a, t2.b
+   Merge Cond: (t1.a = t2.a)
+   ->  Merge Append
+         Sort Key: t1.a
          ->  Nested Loop
                Output: t1_1.a, t1_1.b, t3_1.a
                ->  Unique
@@ -863,15 +864,6 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t1_1
                      Output: t1_1.a, t1_1.b
                      Index Cond: (t1_1.a = t3_1.a)
-         ->  Memoize
-               Output: t2_1.a, t2_1.b
-               Cache Key: t1_1.a
-               Cache Mode: logical
-               ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t2_1
-                     Output: t2_1.a, t2_1.b
-                     Index Cond: (t2_1.a = t1_1.a)
-   ->  Nested Loop
-         Output: t1_2.a, t1_2.b, t2_2.a, t2_2.b
          ->  Nested Loop
                Output: t1_2.a, t1_2.b, t3_2.a
                ->  Unique
@@ -881,15 +873,6 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t1_2
                      Output: t1_2.a, t1_2.b
                      Index Cond: (t1_2.a = t3_2.a)
-         ->  Memoize
-               Output: t2_2.a, t2_2.b
-               Cache Key: t1_2.a
-               Cache Mode: logical
-               ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t2_2
-                     Output: t2_2.a, t2_2.b
-                     Index Cond: (t2_2.a = t1_2.a)
-   ->  Nested Loop
-         Output: t1_3.a, t1_3.b, t2_3.a, t2_3.b
          ->  Nested Loop
                Output: t1_3.a, t1_3.b, t3_3.a
                ->  Unique
@@ -902,14 +885,16 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p3_a_idx on public.unique_tbl_p3 t1_3
                      Output: t1_3.a, t1_3.b
                      Index Cond: (t1_3.a = t3_3.a)
-         ->  Memoize
-               Output: t2_3.a, t2_3.b
-               Cache Key: t1_3.a
-               Cache Mode: logical
+   ->  Materialize
+         Output: t2.a, t2.b
+         ->  Append
+               ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t2_1
+                     Output: t2_1.a, t2_1.b
+               ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t2_2
+                     Output: t2_2.a, t2_2.b
                ->  Index Scan using unique_tbl_p3_a_idx on public.unique_tbl_p3 t2_3
                      Output: t2_3.a, t2_3.b
-                     Index Cond: (t2_3.a = t1_3.a)
-(59 rows)
+(44 rows)
 
 reset enable_partitionwise_join;
 drop table unique_tbl_p;
-- 
2.51.0



  [application/octet-stream] v2-0006-WIP-Add-pg_plan_advice-contrib-module.patch (359.6K, 7-v2-0006-WIP-Add-pg_plan_advice-contrib-module.patch)
  download | inline diff:
From d7257d275aa7df326be7ee07b64bdd5c8d46b31f Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 4 Nov 2025 14:45:31 -0500
Subject: [PATCH v2 6/6] WIP: Add pg_plan_advice contrib module.

Provide a facility that (1) can be used to stabilize certain plan choices
so that the planner cannot reverse course without authorization and
(2) can be used by knowledgeable users to insist on plan choices contrary
to what the planner believes best. In both cases, terrible outcomes are
possible: users should think twice and perhaps three times before
constraining the planner's ability to do as it thinks best; nevertheless,
there are problems that are much more easily solved with these facilities
than without them.

We take the approach of analyzing a finished plan to produce textual
output, which we call "plan advice", that describes key decisions made
during plan; if that plan advice is provided during future planning
cycles, it will force those key decisions to be made in the same way.
Not all planner decisions can be controlled using advice; for example,
decisions about how to perform aggregation are currently out of scope,
as is choice of sort order. Plan advice can also be edited by the user,
or even written from scratch in simple cases, making it possible to
generate outcomes that the planner would not have produced. Partial
advice can be provided to control some planner outcomes but not others.

Currently, plan advice is focused only on specific outcomes, such as
the choice to use a sequential scan for a particular relation, and not
on estimates that might contribute to those outcomes, such as a
possibly-incorrect selectivity estimate. While it would be useful to
users to be able to provide plan advice that affects selectivity
estimates or other aspects of costing, that is out of scope for this
commit.

For more details, see contrib/pg_plan_advice/README.

NOTE: This code is just a proof of concept. A bunch of things don't
work and a lot of the code needs cleanup. It has no SGML documentation
and not enough test cases, and some of the existing test cases don't
do as we would hope. Known problems are called out by XXX.
---
 contrib/Makefile                              |    1 +
 contrib/meson.build                           |    1 +
 contrib/pg_plan_advice/.gitignore             |    3 +
 contrib/pg_plan_advice/Makefile               |   45 +
 contrib/pg_plan_advice/README                 |  275 +++
 contrib/pg_plan_advice/expected/gather.out    |  319 +++
 .../pg_plan_advice/expected/join_order.out    |  292 +++
 .../pg_plan_advice/expected/join_strategy.out |  297 +++
 .../pg_plan_advice/expected/partitionwise.out |  243 +++
 contrib/pg_plan_advice/expected/scan.out      |  757 ++++++++
 contrib/pg_plan_advice/meson.build            |   63 +
 .../pg_plan_advice/pg_plan_advice--1.0.sql    |   42 +
 contrib/pg_plan_advice/pg_plan_advice.c       |  454 +++++
 contrib/pg_plan_advice/pg_plan_advice.control |    5 +
 contrib/pg_plan_advice/pg_plan_advice.h       |   37 +
 contrib/pg_plan_advice/pgpa_ast.c             |  392 ++++
 contrib/pg_plan_advice/pgpa_ast.h             |  204 ++
 contrib/pg_plan_advice/pgpa_collector.c       |  626 ++++++
 contrib/pg_plan_advice/pgpa_collector.h       |   18 +
 contrib/pg_plan_advice/pgpa_identifier.c      |  476 +++++
 contrib/pg_plan_advice/pgpa_identifier.h      |   52 +
 contrib/pg_plan_advice/pgpa_join.c            |  615 ++++++
 contrib/pg_plan_advice/pgpa_join.h            |  105 +
 contrib/pg_plan_advice/pgpa_output.c          |  628 ++++++
 contrib/pg_plan_advice/pgpa_output.h          |   22 +
 contrib/pg_plan_advice/pgpa_parser.y          |  337 ++++
 contrib/pg_plan_advice/pgpa_planner.c         | 1706 +++++++++++++++++
 contrib/pg_plan_advice/pgpa_planner.h         |   17 +
 contrib/pg_plan_advice/pgpa_scan.c            |  278 +++
 contrib/pg_plan_advice/pgpa_scan.h            |   86 +
 contrib/pg_plan_advice/pgpa_scanner.l         |  299 +++
 contrib/pg_plan_advice/pgpa_trove.c           |  490 +++++
 contrib/pg_plan_advice/pgpa_trove.h           |  113 ++
 contrib/pg_plan_advice/pgpa_walker.c          |  862 +++++++++
 contrib/pg_plan_advice/pgpa_walker.h          |  121 ++
 contrib/pg_plan_advice/sql/gather.sql         |   75 +
 contrib/pg_plan_advice/sql/join_order.sql     |   96 +
 contrib/pg_plan_advice/sql/join_strategy.sql  |   76 +
 contrib/pg_plan_advice/sql/partitionwise.sql  |   78 +
 contrib/pg_plan_advice/sql/scan.sql           |  195 ++
 src/tools/pgindent/typedefs.list              |   37 +
 41 files changed, 10838 insertions(+)
 create mode 100644 contrib/pg_plan_advice/.gitignore
 create mode 100644 contrib/pg_plan_advice/Makefile
 create mode 100644 contrib/pg_plan_advice/README
 create mode 100644 contrib/pg_plan_advice/expected/gather.out
 create mode 100644 contrib/pg_plan_advice/expected/join_order.out
 create mode 100644 contrib/pg_plan_advice/expected/join_strategy.out
 create mode 100644 contrib/pg_plan_advice/expected/partitionwise.out
 create mode 100644 contrib/pg_plan_advice/expected/scan.out
 create mode 100644 contrib/pg_plan_advice/meson.build
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice--1.0.sql
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.c
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.control
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.h
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.c
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.h
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.c
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.h
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.c
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.h
 create mode 100644 contrib/pg_plan_advice/pgpa_join.c
 create mode 100644 contrib/pg_plan_advice/pgpa_join.h
 create mode 100644 contrib/pg_plan_advice/pgpa_output.c
 create mode 100644 contrib/pg_plan_advice/pgpa_output.h
 create mode 100644 contrib/pg_plan_advice/pgpa_parser.y
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.c
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.c
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scanner.l
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.c
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.h
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.c
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.h
 create mode 100644 contrib/pg_plan_advice/sql/gather.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_order.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_strategy.sql
 create mode 100644 contrib/pg_plan_advice/sql/partitionwise.sql
 create mode 100644 contrib/pg_plan_advice/sql/scan.sql

diff --git a/contrib/Makefile b/contrib/Makefile
index 2f0a88d3f77..dd04c20acd2 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -34,6 +34,7 @@ SUBDIRS = \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
+		pg_plan_advice \
 		pg_prewarm	\
 		pg_stat_statements \
 		pg_surgery	\
diff --git a/contrib/meson.build b/contrib/meson.build
index ed30ee7d639..cb718dbdac0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -48,6 +48,7 @@ subdir('pgcrypto')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
+subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
 subdir('pg_stat_statements')
diff --git a/contrib/pg_plan_advice/.gitignore b/contrib/pg_plan_advice/.gitignore
new file mode 100644
index 00000000000..19a14253019
--- /dev/null
+++ b/contrib/pg_plan_advice/.gitignore
@@ -0,0 +1,3 @@
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
new file mode 100644
index 00000000000..27d3451d574
--- /dev/null
+++ b/contrib/pg_plan_advice/Makefile
@@ -0,0 +1,45 @@
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_plan_advice.o \
+	pgpa_ast.o \
+	pgpa_collector.o \
+	pgpa_identifier.o \
+	pgpa_join.o \
+	pgpa_output.o \
+	pgpa_parser.o \
+	pgpa_planner.o \
+	pgpa_scan.o \
+	pgpa_scanner.o \
+	pgpa_trove.o \
+	pgpa_walker.o
+
+EXTENSION = pg_plan_advice
+DATA = pg_plan_advice--1.0.sql
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise scan
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+	touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
new file mode 100644
index 00000000000..4590cd03ce5
--- /dev/null
+++ b/contrib/pg_plan_advice/README
@@ -0,0 +1,275 @@
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planing
+cycle, each piece of advice should be guaranteed to apply to the exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should succesfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it is appears to be good advice or bad advice.  If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+   e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously.  Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables.  When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by curly brances, e.g. SOME_SORT_OF_SCAN(foo (bar baz)) would mean that the
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+Bitmap heap scans allow for a more complicated index specification. For
+example, BITMAP_HEAP_SCAN(foo &&(foo_a_idx ||(foo_b_idx foo_c_idx))) says
+that foo should be scanned using a BitmapHeapScan over a BitmapAnd between
+foo_a_idx and the result of a BitmapOr between foo_b_idx and foo_c_idx.
+
+XXX: Currently, BITMAP_HEAP_SCAN does not enforce the index specification,
+because the available hooks are insufficient to do so. It's possible that
+this should be changed to exclude the index specification altogether and
+simply insist that some sort of bitmap heap scan is used; alternatively,
+we need better hooks.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ORDER() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructured. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Strategy Advice
+====================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+strategy advice; no join strategy advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join strategy advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the planner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join strategy advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join strategy advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join strategy advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+XXX: Currently, SEMIJOIN_NON_UNIQUE() advice is emitted in some situations
+where the SEMIJOIN_UNIQUE() approach was determined to be non-viable; ideally,
+we should avoid that.
+
+XXX: Right semijoins haven't been properly thought through. The associated
+code probably just doesn't work.
+
+XXX: Semijoin uniqueness advice has no automated tests and need substantially
+more manual testing.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advise can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constraint the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the outer
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constraint won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
new file mode 100644
index 00000000000..45c44aff82a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -0,0 +1,319 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Sort Key: f.dim_id
+   ->  Gather
+         Workers Planned: 1
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(f d)
+(20 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(d)
+   NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(d)
+   NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   NO_GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Disabled: true
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(14 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
new file mode 100644
index 00000000000..e87652370c3
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -0,0 +1,292 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   HASH_JOIN(d1 d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (d1.id = f.dim1_id)
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Hash
+               ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 f d2)
+   HASH_JOIN(f d2)
+   SEQ_SCAN(d1 f d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN(d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Disabled: true
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_PLAIN(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
new file mode 100644
index 00000000000..71ee26a337a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -0,0 +1,297 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Disabled: true
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Materialize
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MATERIALIZE(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Memoize
+         Cache Key: f.dim_id
+         Cache Mode: logical
+         ->  Index Scan using join_dim_pkey on join_dim d
+               Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MEMOIZE(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN              
+-------------------------------------
+ Hash Join
+   Hash Cond: (d.id = f.dim_id)
+   ->  Seq Scan on join_dim d
+   ->  Hash
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   HASH_JOIN(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Materialize
+         ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_MATERIALIZE(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_dim d
+   ->  Materialize
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MATERIALIZE(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Memoize
+         Cache Key: d.id
+         Cache Mode: logical
+         ->  Index Scan using join_fact_dim_id on join_fact f
+               Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MEMOIZE(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+         Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_PLAIN(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(13 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
new file mode 100644
index 00000000000..df0f05531d5
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -0,0 +1,243 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt2.id)
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1b pt1_2
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1c pt1_3
+               Filter: (val1 = 1)
+   ->  Hash
+         ->  Hash Join
+               Hash Cond: (pt2.id = pt3.id)
+               ->  Append
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+               ->  Hash
+                     ->  Append
+                           ->  Seq Scan on pt3a pt3_1
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3b pt3_2
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3c pt3_3
+                                 Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE(pt1) /* matched */
+   PARTITIONWISE(pt2) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 (pt2 pt3))
+   HASH_JOIN(pt3 pt3)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE(pt1 pt2 pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(40 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt3.id)
+   ->  Append
+         ->  Hash Join
+               Hash Cond: (pt1_1.id = pt2_1.id)
+               ->  Seq Scan on pt1a pt1_1
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_2.id = pt2_2.id)
+               ->  Seq Scan on pt1b pt1_2
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_3.id = pt2_3.id)
+               ->  Seq Scan on pt1c pt1_3
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+   ->  Hash
+         ->  Append
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3b pt3_2
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3c pt3_3
+                     Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+   JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+   JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+   JOIN_ORDER({pt1 pt2} pt3)
+   HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+   SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+    pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE((pt1 pt2) pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+         ->  Seq Scan on pt1b pt1_2
+         ->  Seq Scan on pt1c pt1_3
+   ->  Append
+         ->  Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+               Index Cond: (id = pt1.id)
+         ->  Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+               Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 ptmismatch)
+   NESTED_LOOP_PLAIN(ptmismatch)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+    ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+   PARTITIONWISE(pt1 ptmismatch)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+    ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
new file mode 100644
index 00000000000..61f361fcf9c
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -0,0 +1,757 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+       QUERY PLAN        
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                     QUERY PLAN                     
+----------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+            QUERY PLAN             
+-----------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_b) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a > 0)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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 > 0;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a > 0)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (a > 0)
+   ->  Bitmap Index Scan on scan_table_pkey
+         Index Cond: (a > 0)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(9 rows)
+
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_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:
+   TID_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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table cilbup.scan_table_pkey) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, conflicting */
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched, conflicting */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Nested Loop Left Join
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s s#2)
+   INDEX_SCAN(s public.scan_table_pkey s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop Left Join
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s#2)
+   HASH_JOIN(s)
+   SEQ_SCAN(s)
+   INDEX_SCAN(s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s)
+   HASH_JOIN(s#2)
+   SEQ_SCAN(s#2)
+   INDEX_SCAN(s public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   HASH_JOIN(s s#2)
+   SEQ_SCAN(s s#2)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+COMMIT;
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(5 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(5 rows)
+
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+          QUERY PLAN           
+-------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@x)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                    QUERY PLAN                    
+--------------------------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
new file mode 100644
index 00000000000..dafb3fce9b5
--- /dev/null
+++ b/contrib/pg_plan_advice/meson.build
@@ -0,0 +1,63 @@
+# Copyright (c) 2022-2024, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+  'pg_plan_advice.c',
+  'pgpa_ast.c',
+  'pgpa_collector.c',
+  'pgpa_identifier.c',
+  'pgpa_join.c',
+  'pgpa_output.c',
+  'pgpa_planner.c',
+  'pgpa_scan.c',
+  'pgpa_trove.c',
+  'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+  input: 'pgpa_scanner.l',
+  output: 'pgpa_scanner.c',
+  command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+  input: 'pgpa_parser.y',
+  kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+  pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_plan_advice',
+    '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice = shared_module('pg_plan_advice',
+  pg_plan_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_data(
+  'pg_plan_advice--1.0.sql',
+  'pg_plan_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gather',
+      'join_order',
+      'join_strategy',
+      'partitionwise',
+      'scan',
+    ],
+  },
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice--1.0.sql b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
new file mode 100644
index 00000000000..29f4f224864
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
@@ -0,0 +1,42 @@
+/* contrib/pg_plan_advice/pg_plan_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_plan_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_plan_advice/pg_plan_advice.c b/contrib/pg_plan_advice/pg_plan_advice.c
new file mode 100644
index 00000000000..f32e8b7a0d3
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.c
@@ -0,0 +1,454 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ *	  main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static pgpa_shared_state *pgpa_state = NULL;
+static dsa_area *pgpa_dsa_area = NULL;
+
+/* GUC variables */
+char	   *pg_plan_advice_advice = NULL;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+int			pg_plan_advice_local_collection_limit = 0;
+int			pg_plan_advice_shared_collection_limit = 0;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int	es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+												  DefElem *opt,
+												  ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+												 IntoClause *into,
+												 ExplainState *es,
+												 const char *queryString,
+												 ParamListInfo params,
+												 QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+											 GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("pg_plan_advice.advice",
+							   "advice to apply during query planning",
+							   NULL,
+							   &pg_plan_advice_advice,
+							   NULL,
+							   PGC_USERSET,
+							   0,
+							   pg_plan_advice_advice_check_hook,
+							   NULL,
+							   NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+							 "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+							 NULL,
+							 &pg_plan_advice_always_explain_supplied_advice,
+							 true,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_plan_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_plan_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_plan_advice");
+
+	/* Get an ID that we can use to cache data in an ExplainState. */
+	es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+	/* Register the new EXPLAIN options implemented by this module. */
+	RegisterExtensionExplainOption("plan_advice",
+								   pg_plan_advice_explain_option_handler);
+
+	/* Install hooks */
+	pgpa_planner_install_hooks();
+	prev_explain_per_plan = explain_per_plan_hook;
+	explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgpa_init_shared_state(void *ptr)
+{
+	pgpa_shared_state *state = (pgpa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock, LWLockNewTrancheId("pg_plan_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_plan_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+	if (pgpa_memory_context == NULL)
+		pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_plan_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgpa_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ *
+ * Along the way, make sure the relevant LWLock tranches are registered.
+ */
+pgpa_shared_state *
+pg_plan_advice_attach(void)
+{
+	if (pgpa_state == NULL)
+	{
+		bool		found;
+
+		pgpa_state =
+			GetNamedDSMSegment("pg_plan_advice", sizeof(pgpa_shared_state),
+							   pgpa_init_shared_state, &found);
+	}
+
+	return pgpa_state;
+}
+
+/*
+ * Return a pointer to pg_plan_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_plan_advice_dsa_area(void)
+{
+	if (pgpa_dsa_area == NULL)
+	{
+		pgpa_shared_state *state = pg_plan_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgpa_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgpa_dsa_area);
+			state->area = dsa_get_handle(pgpa_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgpa_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgpa_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgpa_dsa_area;
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+									  ParseState *pstate)
+{
+	bool	   *plan_advice;
+
+	plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+	if (plan_advice == NULL)
+	{
+		plan_advice = palloc0_object(bool);
+		SetExplainExtensionState(es, es_extension_id, plan_advice);
+	}
+
+	*plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+									  char *value)
+{
+	char	   *s;
+
+	/* For non-text formats, it's best not to add any special handling. */
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		ExplainPropertyText(qlabel, value, es);
+		return;
+	}
+
+	/* In text format, if there is no data, display nothing. */
+	if (*qlabel == '\0')
+		return;
+
+	/*
+	 * It looks nicest to indent each line of the advice separately, beginning
+	 * on the line below the label.
+	 */
+	ExplainIndentText(es);
+	appendStringInfo(es->str, "%s:\n", qlabel);
+	es->indent++;
+	while ((s = strchr(value, '\n')) != NULL)
+	{
+		ExplainIndentText(es);
+		appendBinaryStringInfo(es->str, value, (s - value) + 1);
+		value = s + 1;
+	}
+
+	/* Don't interpret a terminal newline as a request for an empty line. */
+	if (*value != '\0')
+	{
+		ExplainIndentText(es);
+		appendStringInfo(es->str, "%s\n", value);
+	}
+
+	es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		appendStringInfo(&buf, "%s /* ", item->defname);
+		if ((flags & PGPA_TE_MATCH_FULL) != 0)
+		{
+			Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+			appendStringInfo(&buf, "matched");
+		}
+		else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+			appendStringInfo(&buf, "partially matched");
+		else
+			appendStringInfo(&buf, "not matched");
+		if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+			appendStringInfo(&buf, ", inapplicable");
+		if ((flags & PGPA_TE_CONFLICTING) != 0)
+			appendStringInfo(&buf, ", conflicting");
+		if ((flags & PGPA_TE_FAILED) != 0)
+			appendStringInfo(&buf, ", failed");
+		appendStringInfo(&buf, " */\n");
+	}
+
+	pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+										  buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+									 IntoClause *into,
+									 ExplainState *es,
+									 const char *queryString,
+									 ParamListInfo params,
+									 QueryEnvironment *queryEnv)
+{
+	bool	   *plan_advice = GetExplainExtensionState(es, es_extension_id);
+	DefElem    *pgpa_item;
+	List	   *pgpa_list;
+
+	if (prev_explain_per_plan)
+		prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+							  queryEnv);
+
+	/* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+	pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+										"pg_plan_advice");
+	pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+	/*
+	 * By default, if there is a record of attempting to apply advice during
+	 * query planning, we always output that information, but the user can set
+	 * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+	 * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+	 * was specified and not set to false.
+	 *
+	 * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+	 * statement -- the application of query advice may not have been
+	 * recorded, and therefore this won't be able to show anything.
+	 */
+	if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+							  (plan_advice != NULL && *plan_advice)))
+	{
+		DefElem    *feedback;
+
+		feedback = find_defelem_by_defname(pgpa_list, "feedback");
+		if (feedback != NULL)
+			pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+	}
+
+	/*
+	 * If the PLAN_ADVICE option was specified -- and not sent to FALSE --
+	 * show generated advice.
+	 */
+	if (plan_advice != NULL && *plan_advice)
+	{
+		DefElem    *advice_string_item;
+		char	   *advice_string;
+
+		advice_string_item =
+			find_defelem_by_defname(pgpa_list, "advice_string");
+		if (advice_string_item != NULL)
+		{
+			/* Advice has already been generated; we can reuse it. */
+			advice_string = strVal(advice_string_item->arg);
+		}
+		else
+		{
+			pgpa_plan_walker_context walker;
+			StringInfoData buf;
+			pgpa_identifier *rt_identifiers;
+
+			/* Advice not yet generated; do that now. */
+			pgpa_plan_walker(&walker, plannedstmt);
+			rt_identifiers =
+				pgpa_create_identifiers_for_planned_stmt(plannedstmt);
+			initStringInfo(&buf);
+			pgpa_output_advice(&buf, &walker, rt_identifiers);
+			advice_string = buf.data;
+		}
+
+		if (advice_string[0] != '\0')
+			pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+												  advice_string);
+	}
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+	MemoryContext oldcontext;
+	MemoryContext tmpcontext;
+	char	   *error;
+
+	if (*newval == NULL)
+		return true;
+
+	tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+									   "pg_plan_advice.advice",
+									   ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+	/*
+	 * It would be nice to save the parse tree that we construct here for
+	 * eventual use when planning with this advice, but *extra can only point
+	 * to a single guc_malloc'd chunk, and our parse tree involves an
+	 * arbitrary number of memory allocations.
+	 */
+	(void) pgpa_parse(*newval, &error);
+
+	if (error != NULL)
+	{
+		GUC_check_errdetail("Could not parse advice: %s", error);
+		return false;
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+	MemoryContextDelete(tmpcontext);
+
+	return true;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.control b/contrib/pg_plan_advice/pg_plan_advice.control
new file mode 100644
index 00000000000..aa6fdc9e7b2
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.control
@@ -0,0 +1,5 @@
+# pg_plan_advice extension
+comment = 'help the planner get the right plan'
+default_version = '1.0'
+module_pathname = '$libdir/pg_plan_advice'
+relocatable = true
diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
new file mode 100644
index 00000000000..86efb3b6113
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -0,0 +1,37 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ *	  main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "nodes/plannodes.h"
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgpa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgpa_shared_state;
+
+/* GUC variables */
+extern int	pg_plan_advice_local_collection_limit;
+extern int	pg_plan_advice_shared_collection_limit;
+extern char *pg_plan_advice_advice;
+
+/* Function prototypes */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern pgpa_shared_state *pg_plan_advice_attach(void);
+extern dsa_area *pg_plan_advice_dsa_area(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
new file mode 100644
index 00000000000..02ffbfa3760
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -0,0 +1,392 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ *	  additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+										  pgpa_advice_target *target,
+										  bool *rids_used);
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+	switch (advice_tag)
+	{
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_FOREIGN_JOIN:
+			return "FOREIGN_JOIN";
+		case PGPA_TAG_GATHER:
+			return "GATHER";
+		case PGPA_TAG_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPA_TAG_HASH_JOIN:
+			return "HASH_JOIN";
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_TAG_INDEX_SCAN:
+			return "INDEX_SCAN";
+		case PGPA_TAG_JOIN_ORDER:
+			return "JOIN_ORDER";
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case PGPA_TAG_NO_GATHER:
+			return "NO_GATHER";
+		case PGPA_TAG_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+		case PGPA_TAG_SEQ_SCAN:
+			return "SEQ_SCAN";
+		case PGPA_TAG_TID_SCAN:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and reurn an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+	*fail = false;
+
+	switch (tag[0])
+	{
+		case 'b':
+			if (strcmp(tag, "bitmap_heap_scan") == 0)
+				return PGPA_TAG_BITMAP_HEAP_SCAN;
+			break;
+		case 'f':
+			if (strcmp(tag, "foreign_join") == 0)
+				return PGPA_TAG_FOREIGN_JOIN;
+			break;
+		case 'g':
+			if (strcmp(tag, "gather") == 0)
+				return PGPA_TAG_GATHER;
+			if (strcmp(tag, "gather_merge") == 0)
+				return PGPA_TAG_GATHER_MERGE;
+			break;
+		case 'h':
+			if (strcmp(tag, "hash_join") == 0)
+				return PGPA_TAG_HASH_JOIN;
+			break;
+		case 'i':
+			if (strcmp(tag, "index_scan") == 0)
+				return PGPA_TAG_INDEX_SCAN;
+			if (strcmp(tag, "index_only_scan") == 0)
+				return PGPA_TAG_INDEX_ONLY_SCAN;
+			break;
+		case 'j':
+			if (strcmp(tag, "join_order") == 0)
+				return PGPA_TAG_JOIN_ORDER;
+			break;
+		case 'm':
+			if (strcmp(tag, "merge_join_materialize") == 0)
+				return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+			if (strcmp(tag, "merge_join_plain") == 0)
+				return PGPA_TAG_MERGE_JOIN_PLAIN;
+			break;
+		case 'n':
+			if (strcmp(tag, "nested_loop_materialize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+			if (strcmp(tag, "nested_loop_memoize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+			if (strcmp(tag, "nested_loop_plain") == 0)
+				return PGPA_TAG_NESTED_LOOP_PLAIN;
+			if (strcmp(tag, "no_gather") == 0)
+				return PGPA_TAG_NO_GATHER;
+			break;
+		case 'p':
+			if (strcmp(tag, "partitionwise") == 0)
+				return PGPA_TAG_PARTITIONWISE;
+			break;
+		case 's':
+			if (strcmp(tag, "semijoin_non_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+			if (strcmp(tag, "semijoin_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_UNIQUE;
+			if (strcmp(tag, "seq_scan") == 0)
+				return PGPA_TAG_SEQ_SCAN;
+			break;
+		case 't':
+			if (strcmp(tag, "tid_scan") == 0)
+				return PGPA_TAG_TID_SCAN;
+			break;
+	}
+
+	/* didn't work out */
+	*fail = true;
+
+	/* return an arbitrary value to unwind the call stack */
+	return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		bool		first = true;
+		char	   *delims;
+
+		if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+			delims = "{}";
+		else
+			delims = "()";
+
+		appendStringInfoChar(str, delims[0]);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_advice_target(str, child_target);
+		}
+		appendStringInfoChar(str, delims[1]);
+	}
+	else
+	{
+		const char *rt_identifier;
+
+		rt_identifier = pgpa_identifier_string(&target->rid);
+		appendStringInfoString(str, rt_identifier);
+	}
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+	if (itarget->itype != PGPA_INDEX_NAME)
+	{
+		bool		first = true;
+
+		if (itarget->itype == PGPA_INDEX_AND)
+			appendStringInfoString(str, "&&(");
+		else
+			appendStringInfoString(str, "||(");
+
+		foreach_ptr(pgpa_index_target, child_target, itarget->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_index_target(str, child_target);
+		}
+		appendStringInfoChar(str, ')');
+	}
+	else
+	{
+		if (itarget->indnamespace != NULL)
+			appendStringInfo(str, "%s.",
+							 quote_identifier(itarget->indnamespace));
+		appendStringInfoString(str, quote_identifier(itarget->indname));
+	}
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+	if (i1->itype != i2->itype)
+		return false;
+
+	if (i1->itype == PGPA_INDEX_NAME)
+	{
+		/* indnamespace can be NULL, and two NULL values are equal */
+		if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+			(i1->indnamespace == NULL || i2->indnamespace == NULL ||
+			 strcmp(i1->indnamespace, i2->indnamespace) != 0))
+			return false;
+		if (strcmp(i1->indname, i2->indname) != 0)
+			return false;
+	}
+	else
+	{
+		int			i1_length = list_length(i1->children);
+
+		if (i1_length != list_length(i2->children))
+			return false;
+		for (int n = 0; n < i1_length; ++n)
+		{
+			pgpa_index_target *c1 = list_nth(i1->children, n);
+			pgpa_index_target *c2 = list_nth(i2->children, n);
+
+			if (!pgpa_index_targets_equal(c1, c2))
+				return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+	/* For non-identifiers, check all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (pgpa_identifier_matches_target(rid, child_target))
+				return true;
+		}
+		return false;
+	}
+
+	if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+		return false;
+	if (rid->occurrence != target->rid.occurrence)
+		return false;
+
+	/*
+	 * The identifier must specify a schema, but the target may leave the
+	 * schema NULL to match anything.
+	 */
+	if (target->rid.partnsp != NULL &&
+		strcmp(rid->partnsp, target->rid.partnsp) != 0)
+		return false;
+
+
+	/*
+	 * These fields can be NULL on either side, but NULL only matches another
+	 * NULL.
+	 */
+	if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+		return false;
+	if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+		return false;
+
+	return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target)
+{
+	bool		all_rids_used = true;
+	bool		any_rids_used = false;
+	bool		all_targets_used;
+	bool	   *rids_used = palloc0_array(bool, nrids);
+
+	all_targets_used =
+		pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+	for (int i = 0; i < nrids; ++i)
+	{
+		if (rids_used[i])
+			any_rids_used = true;
+		else
+			all_rids_used = false;
+	}
+
+	if (all_rids_used)
+	{
+		if (all_targets_used)
+			return PGPA_ITM_EQUAL;
+		else
+			return PGPA_ITM_KEYS_ARE_SUBSET;
+	}
+	else
+	{
+		if (all_targets_used)
+			return PGPA_ITM_TARGETS_ARE_SUBSET;
+		else if (any_rids_used)
+			return PGPA_ITM_INTERSECTING;
+		else
+			return PGPA_ITM_DISJOINT;
+	}
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target, bool *rids_used)
+{
+	bool		result = false;
+
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		result = true;
+
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+											   rids_used))
+				result = false;
+		}
+	}
+	else
+	{
+		for (int i = 0; i < nrids; ++i)
+		{
+			if (pgpa_identifier_matches_target(&rids[i], target))
+			{
+				rids_used[i] = true;
+				result = true;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
new file mode 100644
index 00000000000..f6fe730a4d4
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -0,0 +1,204 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ *	  abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+	PGPA_TARGET_IDENTIFIER,		/* relation identifier */
+	PGPA_TARGET_ORDERED_LIST,	/* (item ...) */
+	PGPA_TARGET_UNORDERED_LIST	/* {item ...} */
+} pgpa_target_type;
+
+/*
+ * When an advice item describes a bitmap index scan, it may need to describe
+ * the use of multiple indexes.
+ */
+typedef enum
+{
+	PGPA_INDEX_NAME,			/* index schema + name */
+	PGPA_INDEX_AND,				/* &&(item ...) */
+	PGPA_INDEX_OR				/* ||(item ...) */
+} pgpa_index_type;
+
+/*
+ * An index specification. We use this for INDEX_SCAN, INDEX_ONLY_SCAN,
+ * and BITMAP_HEAP_SCAN advice, but in the former two cases, the target must
+ * be of type PGPA_INDEX_NAME.
+ */
+typedef struct pgpa_index_target
+{
+	pgpa_index_type itype;
+
+	/* Index schem and name, when itype == PGPA_INDEX_NAME */
+	char	   *indnamespace;
+	char	   *indname;
+
+	/* List of pgpa_index_target objects, when itype != PGPA_INDEX_NAME */
+	List	   *children;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+	pgpa_target_type ttype;
+
+	/*
+	 * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+	 *
+	 * All identifiers must have an alias name and an occurrence number; the
+	 * remaining fields can be NULL. Note that it's possible to specify a
+	 * partition name without a partition schema, but not the reverse.
+	 */
+	pgpa_identifier rid;
+
+	/*
+	 * This field is set when ttype is PPGA_TARGET_IDENTIFIER and the advice
+	 * tag is PGPA_TAG_INDEX_SCAN, PGPA_TAG_INDEX_ONLY_SCAN, or
+	 * PGPA_TAG_BITMAP_HEAP_SCAN.
+	 */
+	pgpa_index_target *itarget;
+
+	/*
+	 * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+	 * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+	 */
+	List	   *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_FOREIGN_JOIN,
+	PGPA_TAG_GATHER,
+	PGPA_TAG_GATHER_MERGE,
+	PGPA_TAG_HASH_JOIN,
+	PGPA_TAG_INDEX_ONLY_SCAN,
+	PGPA_TAG_INDEX_SCAN,
+	PGPA_TAG_JOIN_ORDER,
+	PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+	PGPA_TAG_MERGE_JOIN_PLAIN,
+	PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+	PGPA_TAG_NESTED_LOOP_MEMOIZE,
+	PGPA_TAG_NESTED_LOOP_PLAIN,
+	PGPA_TAG_NO_GATHER,
+	PGPA_TAG_PARTITIONWISE,
+	PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+	PGPA_TAG_SEMIJOIN_UNIQUE,
+	PGPA_TAG_SEQ_SCAN,
+	PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+	pgpa_advice_tag_type tag;
+	List	   *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by an
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifeirs and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifeirs and targets were
+ * found.
+ */
+typedef enum
+{
+	PGPA_ITM_EQUAL,
+	PGPA_ITM_KEYS_ARE_SUBSET,
+	PGPA_ITM_TARGETS_ARE_SUBSET,
+	PGPA_ITM_INTERSECTING,
+	PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int	pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+					   char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner,
+						 const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int	pgpa_yyparse(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+										   pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+												   pgpa_identifier *rids,
+												   pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+									 pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+									  pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+									 pgpa_index_target *itarget);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_collector.c b/contrib/pg_plan_advice/pgpa_collector.c
new file mode 100644
index 00000000000..3fa045a0b3c
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.c
@@ -0,0 +1,626 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.c
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgpa_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgpa_collected_advice;
+
+/*
+ * A bunch of pointers to pgpa_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgpa_local_advice_chunk
+{
+	pgpa_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgpa_local_advice_chunk;
+
+/*
+ * Information about all of the pgpa_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgpa_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgpa_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgpa_local_advice_chunk **chunks;
+} pgpa_local_advice;
+
+/*
+ * Just like pgpa_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgpa_shared_advice_chunk;
+
+/*
+ * Just like pgpa_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgpa_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgpa_local_advice *local_collector = NULL;
+static pgpa_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgpa_collected_advice *pgpa_make_collected_advice(Oid userid,
+														 Oid dbid,
+														 uint64 queryId,
+														 TimestampTz timestamp,
+														 const char *query_string,
+														 const char *advice_string,
+														 dsa_area *area,
+														 dsa_pointer *result);
+static void pgpa_store_local_advice(pgpa_collected_advice *ca);
+static void pgpa_trim_local_advice(int limit);
+static void pgpa_store_shared_advice(dsa_pointer ca_pointer);
+static void pgpa_trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgpa_collected_advice */
+static inline const char *
+query_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgpa_collected_advice */
+static inline const char *
+advice_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pgpa_collect_advice(uint64 queryId, const char *query_string,
+					const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_plan_advice_local_collection_limit > 0)
+	{
+		pgpa_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+		ca = pgpa_make_collected_advice(userid, dbid, queryId, now,
+										query_string, advice_string,
+										NULL, NULL);
+		pgpa_store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_plan_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_plan_advice_dsa_area();
+		dsa_pointer ca_pointer;
+
+		pgpa_make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string, area,
+								   &ca_pointer);
+		pgpa_store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgpa_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgpa_collected_advice *
+pgpa_make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+						   TimestampTz timestamp,
+						   const char *query_string,
+						   const char *advice_string,
+						   dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgpa_collected_advice *ca;
+
+	total_length = offsetof(pgpa_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = GetUserId();
+	ca->dbid = MyDatabaseId;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pg_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+pgpa_store_local_advice(pgpa_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgpa_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgpa_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number > la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgpa_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgpa_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_local_advice(pg_plan_advice_local_collection_limit);
+}
+
+/*
+ * Add a pg_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_plan_advice DSA area
+ * and should point to an object of type pgpa_collected_advice.
+ */
+static void
+pgpa_store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	pgpa_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgpa_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgpa_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_shared_advice(area, pg_plan_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_local_advice(int limit)
+{
+	pgpa_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgpa_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_shared_advice(dsa_area *area, int limit)
+{
+	pgpa_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	pgpa_trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice *sa = shared_collector;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_plan_advice/pgpa_collector.h b/contrib/pg_plan_advice/pgpa_collector.h
new file mode 100644
index 00000000000..b6e746a06d7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.h
@@ -0,0 +1,18 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.h
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_COLLECTOR_H
+#define PGPA_COLLECTOR_H
+
+extern void pgpa_collect_advice(uint64 queryId, const char *query_string,
+								const char *advice_string);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_identifier.c b/contrib/pg_plan_advice/pgpa_identifier.c
new file mode 100644
index 00000000000..2fa8075d66e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.c
@@ -0,0 +1,476 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ *	  create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted.  Identifier-style escaping is applied to alias_name and
+ * subquery_name.  Whenever we include child_table_name, we always
+ * schema-qualified name, but writing their own plan advice are not required
+ * to do so.  Identifier-style escaping is applied to the schema and to the
+ * relation names separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+									  List *appinfos);
+static int	pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+								   SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+	const char *result;
+
+	Assert(rid->alias_name != NULL);
+	result = quote_identifier(rid->alias_name);
+
+	Assert(rid->occurrence >= 0);
+	if (rid->occurrence > 1)
+		result = psprintf("%s#%d", result, rid->occurrence);
+
+	if (rid->partrel != NULL)
+	{
+		if (rid->partnsp == NULL)
+			result = psprintf("%s/%s", result,
+							  quote_identifier(rid->partnsp));
+		else
+			result = psprintf("%s/%s.%s", result,
+							  quote_identifier(rid->partnsp),
+							  quote_identifier(rid->partrel));
+	}
+
+	if (rid->plan_name != NULL)
+		result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+	return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+							   pgpa_identifier *rid)
+{
+	Index		top_rti = rti;
+	int			occurrence = 1;
+	RangeTblEntry *rte;
+	RangeTblEntry *top_rte;
+	char	   *partnsp = NULL;
+	char	   *partrel = NULL;
+
+	/*
+	 * If this is a child RTE, find the topmost parent that is still of type
+	 * RTE_RELATION. We do this because we identify children of partitioned
+	 * tables by the name of the child table, but subqueries can also have
+	 * child rels and we don't care about those here.
+	 */
+	for (;;)
+	{
+		AppendRelInfo *appinfo;
+		RangeTblEntry *parent_rte;
+
+		/* append_rel_array can be NULL if there are no children */
+		if (root->append_rel_array == NULL ||
+			(appinfo = root->append_rel_array[top_rti]) == NULL)
+			break;
+
+		parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+		if (parent_rte->rtekind != RTE_RELATION)
+			break;
+
+		top_rti = appinfo->parent_relid;
+	}
+
+	/* Get the range table entries for the RTI and top RTI. */
+	rte = planner_rt_fetch(rti, root);
+	top_rte = planner_rt_fetch(top_rti, root);
+	Assert(rte->rtekind != RTE_JOIN);
+	Assert(top_rte->rtekind != RTE_JOIN);
+
+	/* Work out the correct occurrence number. */
+	for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+		AppendRelInfo *appinfo;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 *
+		 * NB: append_rel_array can be NULL if there are no children
+		 */
+		if (root->append_rel_array != NULL &&
+			(appinfo = root->append_rel_array[prior_rti]) != NULL)
+		{
+			RangeTblEntry *parent_rte;
+
+			parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+			if (parent_rte->rtekind == RTE_RELATION)
+				continue;
+		}
+
+		/* Skip NULL entries and joins. */
+		prior_rte = planner_rt_fetch(prior_rti, root);
+		if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	/* If this is a child table, get the schema and relation names. */
+	if (rti != top_rti)
+	{
+		partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+		partrel = get_rel_name(rte->relid);
+	}
+
+	/* OK, we have all the answers we need. Return them to the caller. */
+	rid->alias_name = top_rte->eref->aliasname;
+	rid->occurrence = occurrence;
+	rid->partnsp = partnsp;
+	rid->partrel = partrel;
+	rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+								   pgpa_identifier *rids)
+{
+	int			count = 0;
+	int			rti = -1;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = planner_rt_fetch(rti, root);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+		pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+	}
+
+	Assert(count > 0);
+	return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+	Index		rtable_length = list_length(pstmt->rtable);
+	pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+	Index	   *top_rti_map;
+	int			rtinfoindex = 0;
+	SubPlanRTInfo *rtinfo = NULL;
+	SubPlanRTInfo *nextrtinfo = NULL;
+
+	/*
+	 * Account for relations addded by inheritance expansion of partitioned
+	 * tables.
+	 */
+	top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+										  pstmt->appendRelations);
+
+	/*
+	 * When we begin iterating, we're processing the portion of the range
+	 * table that originated from the top-level PlannerInfo, so subrtinfo is
+	 * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+	 * portion of the range table we are processing. nextrtinfo is always the
+	 * SubPlanRTInfo that follows the current one, if any, so when we're
+	 * processing the top-level query's portion of the range table, the next
+	 * SubPlanRTInfo is the very first one.
+	 */
+	if (pstmt->subrtinfos != NULL)
+		nextrtinfo = linitial(pstmt->subrtinfos);
+
+	/* Main loop over the range table. */
+	for (Index rti = 1; rti <= rtable_length; rti++)
+	{
+		const char *plan_name;
+		Index		top_rti;
+		RangeTblEntry *rte;
+		RangeTblEntry *top_rte;
+		char	   *partnsp = NULL;
+		char	   *partrel = NULL;
+		int			occurrence;
+		pgpa_identifier *rid;
+
+		/*
+		 * Advance to the next SubPlanRTInfo, if it's time to do that.
+		 *
+		 * This loop probably shouldn't ever iterate more than once, because
+		 * that would imply that a subquery was planned but added nothing to
+		 * the range table; but let's be defensive and assume it can happen.
+		 */
+		while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+		{
+			rtinfo = nextrtinfo;
+			if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+				nextrtinfo = NULL;
+			else
+				nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+		}
+
+		/* Fetch the range table entry, if any. */
+		rte = rt_fetch(rti, pstmt->rtable);
+
+		/*
+		 * We can't and don't need to identify null entries, and we don't want
+		 * to identify join entries.
+		 */
+		if (rte == NULL || rte->rtekind == RTE_JOIN)
+			continue;
+
+		/*
+		 * If this is not a relation added by partitioned table expansion,
+		 * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+		 * we need the information for the top RTI/RTE, and must also fetch
+		 * the partition schema and name.
+		 */
+		top_rti = top_rti_map[rti - 1];
+		if (rti == top_rti)
+			top_rte = rte;
+		else
+		{
+			top_rte = rt_fetch(top_rti, pstmt->rtable);
+			partnsp =
+				get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+			partrel = get_rel_name(rte->relid);
+		}
+
+		/* Compute the correct occurrence number. */
+		occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+											rtinfo, top_rti);
+
+		/* Get the name of the current plan (NULL for toplevel query). */
+		plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+		/* Save all the details we've derived. */
+		rid = &result[rti - 1];
+		rid->alias_name = top_rte->eref->aliasname;
+		rid->occurrence = occurrence;
+		rid->partnsp = partnsp;
+		rid->partrel = partrel;
+		rid->plan_name = plan_name;
+	}
+
+	return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid)
+{
+	Index		result = 0;
+
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+	{
+		pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+		/* If there's no identifier for this RTI, skip it. */
+		if (rti_rid->alias_name == NULL)
+			continue;
+
+		/*
+		 * If it matches, return this RTI. As usual, an omitted partition
+		 * schema matches anything, but partition and plan names must either
+		 * match exactly or be omitted on both sides.
+		 */
+		if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+			rid->occurrence == rti_rid->occurrence &&
+			(rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+			 strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+			strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+			strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+		{
+			if (result != 0)
+			{
+				/* Multiple matches were found. */
+				return 0;
+			}
+			result = rti;
+		}
+	}
+
+	return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+	Index	   *top_rti_map = palloc0_array(Index, rtable_length);
+
+	/* Initially, make every RTI point to itself. */
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+		top_rti_map[rti - 1] = rti;
+
+	/* Update the map for each AppendRelInfo object. */
+	foreach_node(AppendRelInfo, appinfo, appinfos)
+	{
+		Index		parent_rti = appinfo->parent_relid;
+		RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+		/* If the parent is not RTE_RELATION, ignore this entry. */
+		if (parent_rte->rtekind != RTE_RELATION)
+			continue;
+
+		/*
+		 * Map the child to wherever we mapped the parent. Parents always
+		 * precede their children in the AppendRelInfo list, so this should
+		 * work out.
+		 */
+		top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+	}
+
+	return top_rti_map;
+}
+
+/*
+ * Find the occurence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+					   SubPlanRTInfo *rtinfo, Index rti)
+{
+	Index		rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+	int			occurrence = 1;
+	RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+	for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 */
+		if (top_rti_map[prior_rti - 1] != prior_rti)
+			break;
+
+		/* Skip joins. */
+		prior_rte = rt_fetch(prior_rti, rtable);
+		if (prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	return occurrence;
+}
diff --git a/contrib/pg_plan_advice/pgpa_identifier.h b/contrib/pg_plan_advice/pgpa_identifier.h
new file mode 100644
index 00000000000..b000d2b7081
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ *	  create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+	const char *alias_name;
+	int			occurrence;
+	const char *partnsp;
+	const char *partrel;
+	const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+	if (a == b)
+		return true;
+	else if (a == NULL || b == NULL)
+		return false;
+	else
+		return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+										   pgpa_identifier *rid);
+extern int	pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+											   Bitmapset *relids,
+											   pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+											  pgpa_identifier *rt_identifiers,
+											  pgpa_identifier *rid);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
new file mode 100644
index 00000000000..28618764d86
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -0,0 +1,615 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	Plan	   *outer_subplan;
+	ElidedNode *outer_elided_node;
+	bool		outer_beneath_any_gather;
+	pgpa_join_strategy *strategy;
+	Plan	  **inner_subplans;
+	ElidedNode **inner_elided_nodes;
+	pgpa_join_unroller **inner_unrollers;
+	bool	   *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+											  Plan *plan,
+											  Plan **realouter,
+											  Plan **realinner,
+											  ElidedNode **elidedrealouter,
+											  ElidedNode **elidedrealinner,
+											  bool *found_any_outer_gather,
+											  bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+										   bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+									ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+	pgpa_join_unroller *join_unroller;
+
+	join_unroller = palloc0_object(pgpa_join_unroller);
+	join_unroller->nallocated = 4;
+	join_unroller->strategy =
+		palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+	join_unroller->inner_subplans =
+		palloc_array(Plan *, join_unroller->nallocated);
+	join_unroller->inner_elided_nodes =
+		palloc_array(ElidedNode *, join_unroller->nallocated);
+	join_unroller->inner_unrollers =
+		palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+	join_unroller->inner_beneath_any_gather =
+		palloc_array(bool, join_unroller->nallocated);
+
+	return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+				 bool beneath_any_gather,
+				 pgpa_join_unroller *join_unroller,
+				 pgpa_join_unroller **outer_join_unroller,
+				 pgpa_join_unroller **inner_join_unroller)
+{
+	pgpa_join_strategy strategy;
+	Plan	   *realinner,
+			   *realouter;
+	ElidedNode *elidedinner,
+			   *elidedouter;
+	int			n;
+	bool		found_any_outer_gather = false;
+	bool		found_any_inner_gather = false;
+
+	Assert(join_unroller != NULL);
+
+	/*
+	 * We need to pass the join_unroller object down through certain types of
+	 * plan nodes -- anything that's considered part of the join strategy, and
+	 * any other nodes that can occur in a join tree despite not being scans
+	 * or joins.
+	 *
+	 * This includes:
+	 *
+	 * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+	 * strategy,
+	 *
+	 * (2) Gather and Gather Merge nodes, which can occur at any point in the
+	 * join tree where the planner decided to initiate parallelism,
+	 *
+	 * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+	 * or GatherMerge,
+	 *
+	 * (4) Agg and Unique nodes, which can occur when we decide to make the
+	 * nullable side of a semijoin unique and then join the result, and
+	 *
+	 * (5) Result nodes with children, which can be added either to project to
+	 * enforce a one-time filter (but Result nodes without children are
+	 * degenerate scans or joins).
+	 */
+	if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+		|| IsA(plan, Gather) || IsA(plan, GatherMerge)
+		|| is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+		|| is_result_node_with_child(plan))
+	{
+		*outer_join_unroller = join_unroller;
+		return;
+	}
+
+	/*
+	 * Since we've already handled nodes that require pass-through treatment,
+	 * this should be an unrollable join.
+	 */
+	strategy = pgpa_decompose_join(walker, plan,
+								   &realouter, &realinner,
+								   &elidedouter, &elidedinner,
+								   &found_any_outer_gather,
+								   &found_any_inner_gather);
+
+	/* If our workspace is full, expand it. */
+	if (join_unroller->nused >= join_unroller->nallocated)
+	{
+		join_unroller->nallocated *= 2;
+		join_unroller->strategy =
+			repalloc_array(join_unroller->strategy,
+						   pgpa_join_strategy,
+						   join_unroller->nallocated);
+		join_unroller->inner_subplans =
+			repalloc_array(join_unroller->inner_subplans,
+						   Plan *,
+						   join_unroller->nallocated);
+		join_unroller->inner_elided_nodes =
+			repalloc_array(join_unroller->inner_elided_nodes,
+						   ElidedNode *,
+						   join_unroller->nallocated);
+		join_unroller->inner_beneath_any_gather =
+			repalloc_array(join_unroller->inner_beneath_any_gather,
+						   bool,
+						   join_unroller->nallocated);
+		join_unroller->inner_unrollers =
+			repalloc_array(join_unroller->inner_unrollers,
+						   pgpa_join_unroller *,
+						   join_unroller->nallocated);
+	}
+
+	/*
+	 * Since we're flattening outer-deep join trees, it follows that if the
+	 * outer side is still an unrollable join, it should be unrolled into this
+	 * same object. Otherwise, we've reached the limit of what we can unroll
+	 * into this object and must remember the outer side as the final outer
+	 * subplan.
+	 */
+	if (elidedouter == NULL && pgpa_is_join(realouter))
+		*outer_join_unroller = join_unroller;
+	else
+	{
+		join_unroller->outer_subplan = realouter;
+		join_unroller->outer_elided_node = elidedouter;
+		join_unroller->outer_beneath_any_gather =
+			beneath_any_gather || found_any_outer_gather;
+	}
+
+	/*
+	 * Store the inner subplan. If it's an unrollable join, it needs to be
+	 * flattened in turn, but into a new unroller object, not this one.
+	 */
+	n = join_unroller->nused++;
+	join_unroller->strategy[n] = strategy;
+	join_unroller->inner_subplans[n] = realinner;
+	join_unroller->inner_elided_nodes[n] = elidedinner;
+	join_unroller->inner_beneath_any_gather[n] =
+		beneath_any_gather || found_any_inner_gather;
+	if (elidedinner == NULL && pgpa_is_join(realinner))
+		*inner_join_unroller = pgpa_create_join_unroller();
+	else
+		*inner_join_unroller = NULL;
+	join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+						 pgpa_join_unroller *join_unroller)
+{
+	pgpa_unrolled_join *ujoin;
+	int			i;
+
+	/*
+	 * We shouldn't have gone even so far as to create a join unroller unless
+	 * we found at least one unrollable join.
+	 */
+	Assert(join_unroller->nused > 0);
+
+	/* Allocate result structures. */
+	ujoin = palloc0_object(pgpa_unrolled_join);
+	ujoin->ninner = join_unroller->nused;
+	ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+	ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+	/* Handle the outermost join. */
+	ujoin->outer.plan = join_unroller->outer_subplan;
+	ujoin->outer.elided_node = join_unroller->outer_elided_node;
+	ujoin->outer.scan =
+		pgpa_build_scan(walker, ujoin->outer.plan,
+						ujoin->outer.elided_node,
+						join_unroller->outer_beneath_any_gather,
+						true);
+
+	/*
+	 * We want the joins from the deepest part of the plan tree to appear
+	 * first in the result object, but the join unroller adds them in exactly
+	 * the reverse of that order, so we need to flip the order of the arrays
+	 * when constructing the final result.
+	 */
+	for (i = 0; i < join_unroller->nused; ++i)
+	{
+		int			k = join_unroller->nused - i - 1;
+
+		/* Copy strategy, Plan, and ElidedNode. */
+		ujoin->strategy[i] = join_unroller->strategy[k];
+		ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+		ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+		/*
+		 * Fill in remaining details, using either the nested join unroller,
+		 * or by deriving them from the plan and elided nodes.
+		 */
+		if (join_unroller->inner_unrollers[k] != NULL)
+			ujoin->inner[i].unrolled_join =
+				pgpa_build_unrolled_join(walker,
+										 join_unroller->inner_unrollers[k]);
+		else
+			ujoin->inner[i].scan =
+				pgpa_build_scan(walker, ujoin->inner[i].plan,
+								ujoin->inner[i].elided_node,
+								join_unroller->inner_beneath_any_gather[i],
+								true);
+	}
+
+	return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+	pfree(join_unroller->strategy);
+	pfree(join_unroller->inner_subplans);
+	pfree(join_unroller->inner_elided_nodes);
+	pfree(join_unroller->inner_unrollers);
+	pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort note on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any correspoding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+					Plan **realouter, Plan **realinner,
+					ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+					bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+	PlannedStmt *pstmt = walker->pstmt;
+	JoinType	jointype = ((Join *) plan)->jointype;
+	Plan	   *outerplan = plan->lefttree;
+	Plan	   *innerplan = plan->righttree;
+	ElidedNode *elidedouter;
+	ElidedNode *elidedinner;
+	pgpa_join_strategy strategy;
+	bool		uniqueouter;
+	bool		uniqueinner;
+
+	elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+	elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+	*found_any_outer_gather = false;
+	*found_any_inner_gather = false;
+
+	switch (nodeTag(plan))
+	{
+		case T_MergeJoin:
+
+			/*
+			 * The planner may have chosen to place a Material node on the
+			 * inner side of the MergeJoin; if this is present, we record it
+			 * as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+			}
+			else
+				strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+			/*
+			 * For a MergeJoin, either the outer or the inner subplan, or
+			 * both, may have needed to be sorted; we must disregard any Sort
+			 * or IncrementalSort node to find the real inner or outer
+			 * subplan.
+			 */
+			if (elidedouter == NULL && is_sorting_plan(outerplan))
+				elidedouter = pgpa_descend_node(pstmt, &outerplan);
+			if (elidedinner == NULL && is_sorting_plan(innerplan))
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			break;
+
+		case T_NestLoop:
+
+			/*
+			 * The planner may have chosen to place a Material or Memoize node
+			 * on the inner side of the NestLoop; if this is present, we
+			 * record it as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+			}
+			else if (elidedinner == NULL && IsA(innerplan, Memoize))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+			}
+			else
+				strategy = JSTRAT_NESTED_LOOP_PLAIN;
+			break;
+
+		case T_HashJoin:
+
+			/*
+			 * The inner subplan of a HashJoin is always a Hash node; the real
+			 * inner subplan is the Hash node's child.
+			 */
+			Assert(IsA(innerplan, Hash));
+			Assert(elidedinner == NULL);
+			elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			strategy = JSTRAT_HASH_JOIN;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+	}
+
+	/*
+	 * The planner may have decided to implement a semijoin by first making
+	 * the nullable side of the plan unique, and then performing a normal join
+	 * against the result. Therefore, we might need to descend through a
+	 * unique node on either side of the plan.
+	 */
+	uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+	uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+	/*
+	 * The planner may have decided to parallelize part of the join tree, so
+	 * we could find a Gather or Gather Merge node here. Note that, if
+	 * present, this will appear below nodes we considered as part of the join
+	 * strategy, but we could find another uniqueness-enforcing node below the
+	 * Gather or Gather Merge, if present.
+	 */
+	if (elidedouter == NULL)
+	{
+		elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+											  found_any_outer_gather);
+		if (found_any_outer_gather &&
+			pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+			uniqueouter = true;
+	}
+	if (elidedinner == NULL)
+	{
+		elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+											  found_any_inner_gather);
+		if (found_any_inner_gather &&
+			pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+			uniqueinner = true;
+	}
+
+	/*
+	 * It's possible that Result node has been inserted either to project a
+	 * target list or to implement a one-time filter. If so, we can descend
+	 * throught it. Note that a result node without a child would be a
+	 * degenerate scan or join, and not something we could descend through.
+	 *
+	 * XXX. I suspect it's possible for this to happen above the Gather or
+	 * Gather Merge node, too, but apparently we have no test case for that
+	 * scenario.
+	 */
+	if (elidedouter == NULL && is_result_node_with_child(outerplan))
+		elidedouter = pgpa_descend_node(pstmt, &outerplan);
+	if (elidedinner == NULL && is_result_node_with_child(innerplan))
+		elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+	/*
+	 * If this is a semijoin that was converted to an inner join by making one
+	 * side or the other unique, make a note that the inner or outer subplan,
+	 * as appropriate, should be treated as a query plan feature when the main
+	 * tree traversal reaches it.
+	 *
+	 * Conversely, if the planner could have made one side of the join unique
+	 * and thereby converted it to an inner join, and chose not to do so, that
+	 * is also worth noting.
+	 *
+	 * XXX: We admit too much non-unique advice, as in the following example
+	 * from the regression tests: EXPLAIN (PLAN_ADVICE, COSTS OFF) DELETE FROM
+	 * prt1_l WHERE EXISTS (SELECT 1 FROM int4_tbl, LATERAL (SELECT
+	 * int4_tbl.f1 FROM int8_tbl LIMIT 2) ss WHERE prt1_l.c IS NULL). We emit
+	 * SEMIJOIN_NON_UNIQUE((int4_tbl ss)) but create_unique_path() fails in
+	 * this case, so there's no sj-unique version possible.
+	 *
+	 * NB: This code could appear slightly higher up in in this function, but
+	 * none of the nodes through which we just descended should be have
+	 * associated RTIs.
+	 *
+	 * NB: This seems like a somewhat hacky way of passing information up to
+	 * the main tree walk, but I don't currently have a better idea.
+	 */
+	if (uniqueouter)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+	else if (jointype == JOIN_RIGHT_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+	if (uniqueinner)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+	else if (jointype == JOIN_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+	/* Set output parameters. */
+	*realouter = outerplan;
+	*realinner = innerplan;
+	*elidedrealouter = elidedouter;
+	*elidedrealinner = elidedinner;
+	return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+	*plan = (*plan)->lefttree;
+	return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+						bool *found_any_gather)
+{
+	if (IsA(*plan, Gather))
+	{
+		*found_any_gather = true;
+		return pgpa_descend_node(pstmt, plan);
+	}
+
+	if (IsA(*plan, GatherMerge))
+	{
+		ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+		if (elided == NULL && is_sorting_plan(*plan))
+			elided = pgpa_descend_node(pstmt, plan);
+
+		*found_any_gather = true;
+		return elided;
+	}
+
+	return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through at least one node, and
+ * otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+						ElidedNode **elided_node)
+{
+	if (*elided_node != NULL)
+		return false;
+
+	if (IsA(*plan, Agg) || IsA(*plan, Unique))
+	{
+		*elided_node = pgpa_descend_node(pstmt, plan);
+
+		if (*elided_node == NULL && is_sorting_plan(*plan))
+			*elided_node = pgpa_descend_node(pstmt, plan);
+
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+	return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+	return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
diff --git a/contrib/pg_plan_advice/pgpa_join.h b/contrib/pg_plan_advice/pgpa_join.h
new file mode 100644
index 00000000000..4dc72986a70
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.h
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+	JSTRAT_MERGE_JOIN_PLAIN = 0,
+	JSTRAT_MERGE_JOIN_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_PLAIN,
+	JSTRAT_NESTED_LOOP_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_MEMOIZE,
+	JSTRAT_HASH_JOIN
+	/* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY		((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+	Plan	   *plan;
+	ElidedNode *elided_node;
+	struct pgpa_scan *scan;
+	pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>.  When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+	/* Outermost member; must not itself be an unrolled join. */
+	pgpa_join_member outer;
+
+	/* Number of inner members. Length of the strategy and inner arrays. */
+	unsigned	ninner;
+
+	/* Array of strategies, one per non-outermost member. */
+	pgpa_join_strategy *strategy;
+
+	/* Array of members, excluding the outermost. Deepest first. */
+	pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+	return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+							 Plan *plan, bool beneath_any_gather,
+							 pgpa_join_unroller *join_unroller,
+							 pgpa_join_unroller **outer_join_unroller,
+							 pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+													pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
new file mode 100644
index 00000000000..89a675ff93e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -0,0 +1,628 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+	const char **rid_strings;
+	StringInfo	buf;
+	int			wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+									  pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+									pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+									  pgpa_scan_strategy strategy,
+									  List *scans);
+static void pgpa_output_bitmap_index_details(pgpa_output_context *context,
+											 Plan *plan);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+									  pgpa_qf_type type,
+									  List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+										char *strategy,
+										List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+								  Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+								  Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+				   pgpa_identifier *rt_identifiers)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	ListCell   *lc;
+	pgpa_output_context context;
+
+	/* Basic initialization. */
+	memset(&context, 0, sizeof(pgpa_output_context));
+	context.buf = buf;
+
+	/*
+	 * Convert identifiers to string form. Note that the loop variable here is
+	 * not an RTI, because RTIs are 1-based. Some RTIs will have no
+	 * identifier, either because the reloptkind is RTE_JOIN or because that
+	 * portion of the query didn't make it into the final plan.
+	 */
+	context.rid_strings = palloc0_array(const char *, rtable_length);
+	for (int i = 0; i < rtable_length; ++i)
+		if (rt_identifiers[i].alias_name != NULL)
+			context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+	/*
+	 * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+	 * from a psql client with default settings, psql will add one space to
+	 * the left of the output and EXPLAIN will add two more to the left of the
+	 * advice. Thus, lines of more than 77 characters will wrap. We set the
+	 * wrap limit to 76 here so that the output won't reach all the way to the
+	 * very last column of the terminal.
+	 *
+	 * Of course, this is fairly arbitrary set of assumptions, and one could
+	 * well make an argument for a different wrap limit, or for a configurable
+	 * one.
+	 */
+	context.wrap_column = 76;
+
+	/*
+	 * Each piece of JOIN_ORDER() advice fully describes the join order for a
+	 * a single unrolled join. Merging is not permitted, because that would
+	 * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+	 * scans should be used for all of those relations, and is thus equivalent
+	 * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+	 * is the driving table which is then joined to "b" then "c" then "d",
+	 * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+	 */
+	foreach(lc, walker->toplevel_unrolled_joins)
+	{
+		pgpa_unrolled_join *ujoin = lfirst(lc);
+
+		if (buf->len > 0)
+			appendStringInfoChar(buf, '\n');
+		appendStringInfo(context.buf, "JOIN_ORDER(");
+		pgpa_output_unrolled_join(&context, ujoin);
+		appendStringInfoChar(context.buf, ')');
+		pgpa_maybe_linebreak(context.buf, context.wrap_column);
+	}
+
+	/* Emit join strategy advice. */
+	for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+	{
+		char	   *strategy = pgpa_cstring_join_strategy(s);
+
+		pgpa_output_simple_strategy(&context,
+									strategy,
+									walker->join_strategies[s]);
+	}
+
+	/*
+	 * Emit scan strategy advice (but not for ordinary scans, which are
+	 * definitionally uninteresting).
+	 */
+	for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+		if (c != PGPA_SCAN_ORDINARY)
+			pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+	/* Emit query feature advice. */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+		pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+	/* Emit NO_GATHER advice. */
+	pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+						  pgpa_unrolled_join *join)
+{
+	pgpa_output_join_member(context, &join->outer);
+
+	for (int k = 0; k < join->ninner; ++k)
+	{
+		pgpa_join_member *member = &join->inner[k];
+
+		pgpa_maybe_linebreak(context->buf, context->wrap_column);
+		appendStringInfoChar(context->buf, ' ');
+		pgpa_output_join_member(context, member);
+	}
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+						pgpa_join_member *member)
+{
+	if (member->unrolled_join != NULL)
+	{
+		appendStringInfoChar(context->buf, '(');
+		pgpa_output_unrolled_join(context, member->unrolled_join);
+		appendStringInfoChar(context->buf, ')');
+	}
+	else
+	{
+		pgpa_scan  *scan = member->scan;
+
+		Assert(scan != NULL);
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '{');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, '}');
+		}
+	}
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+						  pgpa_scan_strategy strategy,
+						  List *scans)
+{
+	bool		first = true;
+
+	if (scans == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_scan_strategy(strategy));
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		Plan	   *plan = scan->plan;
+
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		/* Output the relation identifiers. */
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+
+		/* For scans involving indexes, output index information. */
+		if (strategy == PGPA_SCAN_INDEX)
+		{
+			Assert(IsA(plan, IndexScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_INDEX_ONLY)
+		{
+			Assert(IsA(plan, IndexOnlyScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context,
+									  ((IndexOnlyScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_BITMAP_HEAP)
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_bitmap_index_details(context, plan->lefttree);
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output information about which index or indexes power a BitmapHeapScan.
+ *
+ * We emit &&(i1 i2 i3) for a BitmapAnd between indexes i1, i2, and i3;
+ * and likewise ||(i1 i2 i3) for a similar BitmapOr operation.
+ */
+static void
+pgpa_output_bitmap_index_details(pgpa_output_context *context, Plan *plan)
+{
+	char	   *operator;
+	List	   *bitmapplans;
+	bool		first = true;
+
+	if (IsA(plan, BitmapIndexScan))
+	{
+		BitmapIndexScan *bitmapindexscan = (BitmapIndexScan *) plan;
+
+		pgpa_output_relation_name(context, bitmapindexscan->indexid);
+		return;
+	}
+
+	if (IsA(plan, BitmapOr))
+	{
+		operator = "||";
+		bitmapplans = ((BitmapOr *) plan)->bitmapplans;
+	}
+	else if (IsA(plan, BitmapAnd))
+	{
+		operator = "&&";
+		bitmapplans = ((BitmapAnd *) plan)->bitmapplans;
+	}
+	else
+		elog(ERROR, "unexpected node type: %d", (int) nodeTag(plan));
+
+	appendStringInfo(context->buf, "%s(", operator);
+	foreach_ptr(Plan, child_plan, bitmapplans)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+		pgpa_output_bitmap_index_details(context, child_plan);
+	}
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+	Oid			nspoid = get_rel_namespace(relid);
+	char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+	char	   *relname = get_rel_name(relid);
+
+	appendStringInfoString(context->buf, quote_identifier(relnamespace));
+	appendStringInfoChar(context->buf, '.');
+	appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+						  List *query_features)
+{
+	bool		first = true;
+
+	if (query_features == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_query_feature_type(type));
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(qf->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, qf->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, qf->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+							List *relid_sets)
+{
+	bool		first = true;
+
+	if (relid_sets == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(", strategy);
+
+	foreach_node(Bitmapset, relids, relid_sets)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+	if (relids == NULL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "NO_GATHER(");
+	pgpa_output_relations(context, context->buf, relids);
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+					  Bitmapset *relids)
+{
+	int			rti = -1;
+	bool		first = true;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		const char *rid_string = context->rid_strings[rti - 1];
+
+		if (rid_string == NULL)
+			elog(ERROR, "no identifier for RTI %d", rti);
+
+		if (first)
+		{
+			first = false;
+			appendStringInfoString(buf, rid_string);
+		}
+		else
+		{
+			pgpa_maybe_linebreak(buf, context->wrap_column);
+			appendStringInfo(buf, " %s", rid_string);
+		}
+	}
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+	switch (strategy)
+	{
+		case JSTRAT_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case JSTRAT_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case JSTRAT_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case JSTRAT_HASH_JOIN:
+			return "HASH_JOIN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+	switch (strategy)
+	{
+		case PGPA_SCAN_ORDINARY:
+			return "ORDINARY_SCAN";
+		case PGPA_SCAN_SEQ:
+			return "SEQ_SCAN";
+		case PGPA_SCAN_BITMAP_HEAP:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_SCAN_FOREIGN:
+			return "FOREIGN_JOIN";
+		case PGPA_SCAN_INDEX:
+			return "INDEX_SCAN";
+		case PGPA_SCAN_INDEX_ONLY:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_SCAN_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_SCAN_TID:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+	switch (type)
+	{
+		case PGPAQF_GATHER:
+			return "GATHER";
+		case PGPAQF_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPAQF_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPAQF_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+	}
+
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+	char	   *trailing_nl;
+	int			line_start;
+	int			save_cursor;
+
+	/* If line wrapping is disabled, exit quickly. */
+	if (wrap_column <= 0)
+		return;
+
+	/*
+	 * Set line_start to the byte offset within buf->data of the first
+	 * character of the current line, where the current line means the last
+	 * one in the buffer. Note that line_start could be the offset of the
+	 * trailing '\0' if the last character in the buffer is a line break.
+	 */
+	trailing_nl = strrchr(buf->data, '\n');
+	if (trailing_nl == NULL)
+		line_start = 0;
+	else
+		line_start = (trailing_nl - buf->data) + 1;
+
+	/*
+	 * Remember that the current end of the buffer is a potential location to
+	 * insert a line break on a future call to this function.
+	 */
+	save_cursor = buf->cursor;
+	buf->cursor = buf->len;
+
+	/* If we haven't passed the wrap column, we don't need a newline. */
+	if (buf->len - line_start <= wrap_column)
+		return;
+
+	/*
+	 * It only makes sense to insert a newline at a position later than the
+	 * beginning of the current line.
+	 */
+	if (buf->cursor <= line_start)
+		return;
+
+	/* Insert a newline at the previous cursor location. */
+	enlargeStringInfo(buf, 1);
+	memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+			buf->len - save_cursor);
+	++buf->cursor;
+	buf->data[++buf->len] = '\0';
+	buf->data[save_cursor] = '\n';
+}
diff --git a/contrib/pg_plan_advice/pgpa_output.h b/contrib/pg_plan_advice/pgpa_output.h
new file mode 100644
index 00000000000..47496d76f52
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+							   pgpa_plan_walker_context *walker,
+							   pgpa_identifier *rt_identifiers);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_parser.y b/contrib/pg_plan_advice/pgpa_parser.y
new file mode 100644
index 00000000000..4617e7f2f64
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_parser.y
@@ -0,0 +1,337 @@
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc.  This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE   pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+	char	   *str;
+	int			integer;
+	List	   *list;
+	pgpa_advice_item *item;
+	pgpa_advice_target *target;
+	pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_BITMAP TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+%token TOK_OR TOK_AND
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list bitmap_sublist bitmap_target_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> bitmap_target_item index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+		{
+			(void) yynerrs;				/* suppress compiler warning */
+			*result = $1;
+		}
+	;
+
+advice_item_list: advice_item_list advice_item
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_JOIN_ORDER;
+			$$->targets = $3;
+		}
+	| TOK_TAG_INDEX '(' index_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "index_only_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+			else if (strcmp($1, "index_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_BITMAP '(' bitmap_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+			$$->targets = $3;
+		}
+	| TOK_TAG_SIMPLE '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "no_gather") == 0)
+				$$->tag = PGPA_TAG_NO_GATHER;
+			else if (strcmp($1, "seq_scan") == 0)
+				$$->tag = PGPA_TAG_SEQ_SCAN;
+			else if (strcmp($1, "tid_scan") == 0)
+				$$->tag = PGPA_TAG_TID_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_GENERIC '(' generic_target_list ')'
+		{
+			bool	fail;
+
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = pgpa_parse_advice_tag($1, &fail);
+			if (fail)
+			{
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "unrecognized advice tag");
+			}
+
+			if ($$->tag == PGPA_TAG_FOREIGN_JOIN)
+			{
+				foreach_ptr(pgpa_advice_target, target, $3)
+				{
+					if (target->ttype == PGPA_TARGET_IDENTIFIER ||
+						list_length(target->children) == 1)
+							pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+										 "FOREIGN_JOIN targets must contain more than one relation identifier");
+				}
+			}
+
+			$$->targets = $3;
+		}
+	;
+
+relation_identifier: identifier opt_ri_occurrence opt_partition opt_plan_name
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_IDENTIFIER;
+			$$->rid.alias_name = $1;
+			$$->rid.occurrence = $2;
+			if (list_length($3) == 2)
+			{
+				$$->rid.partnsp = linitial($3);
+				$$->rid.partrel = lsecond($3);
+			}
+			else if ($3 != NIL)
+				$$->rid.partrel = linitial($3);
+			$$->rid.plan_name = $4;
+		}
+	;
+
+index_name: identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indname = $1;
+		}
+	| identifier '.' identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indnamespace = $1;
+			$$->indname = $3;
+		}
+	;
+
+opt_ri_occurrence:
+	'#' TOK_INTEGER
+		{
+			if ($2 <= 0)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "only positive occurrence numbers are permitted");
+			$$ = $2;
+		}
+	|
+		{
+			/* The default occurrence number is 1. */
+			$$ = 1;
+		}
+	;
+
+identifier: TOK_IDENT
+	| TOK_TAG_JOIN_ORDER
+	| TOK_TAG_INDEX
+	| TOK_TAG_BITMAP
+	| TOK_TAG_SIMPLE
+	| TOK_TAG_GENERIC
+	;
+
+/*
+ * When generating advice, we always schema-qualify the partition name, but
+ * when parsing advice, we accept a specification that lacks one.
+ */
+opt_partition:
+	'/' TOK_IDENT '.' TOK_IDENT
+		{ $$ = list_make2($2, $4); }
+	| '/' TOK_IDENT
+		{ $$ = list_make1($2); }
+	|
+		{ $$ = NIL; }
+	;
+
+opt_plan_name:
+	'@' TOK_IDENT
+		{ $$ = $2; }
+	|
+		{ $$ = NULL; }
+	;
+
+bitmap_target_list: bitmap_target_list relation_identifier bitmap_target_item
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+bitmap_target_item: index_name
+		{ $$ = $1; }
+	| TOK_OR '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_OR;
+			$$->children = $3;
+		}
+	| TOK_AND '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_AND;
+			$$->children = $3;
+		}
+	;
+
+bitmap_sublist: bitmap_sublist bitmap_target_item
+		{ $$ = lappend($1, $2); }
+	| bitmap_target_item
+		{ $$ = list_make1($1); }
+	;
+
+generic_target_list: generic_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| generic_target_list generic_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+generic_sublist: '(' generic_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+index_target_list:
+	  index_target_list relation_identifier index_name
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_target_list: join_order_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| join_order_target_list join_order_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_sublist:
+	'(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	| '{' simple_target_list '}'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_UNORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+simple_target_list: simple_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+%%
+
+/*
+ * Parse an advice_string and return the resulting list of pgpa_advice_item
+ * objects. If a parse error occurs, instead return NULL.
+ *
+ * If the return value is NULL, *error_p will be set to the error message;
+ * otherwise, *error_p will be set to NULL.
+ */
+List *
+pgpa_parse(const char *advice_string, char **error_p)
+{
+	yyscan_t	scanner;
+	List	   *result;
+	char	   *error = NULL;
+
+	pgpa_scanner_init(advice_string, &scanner);
+	pgpa_yyparse(&result, &error, scanner);
+	pgpa_scanner_finish(scanner);
+
+	if (error != NULL)
+	{
+		*error_p = error;
+		return NULL;
+	}
+
+	*error_p = NULL;
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
new file mode 100644
index 00000000000..767faccd8d0
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -0,0 +1,1706 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.c
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "common/hashfn_unstable.h"
+#include "nodes/makefuncs.h"
+#include "optimizer/extendplan.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/plancat.h"
+#include "optimizer/planner.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * When assertions are enabled, we try generating relation identifiers during
+ * planning, saving them in a hash table, and then cross-checking them against
+ * the ones generated after planning is complete.
+ */
+typedef struct pgpa_ri_checker_key
+{
+	char	   *plan_name;
+	Index		rti;
+} pgpa_ri_checker_key;
+
+typedef struct pgpa_ri_checker
+{
+	pgpa_ri_checker_key key;
+	uint32		status;
+	const char *rid_string;
+} pgpa_ri_checker;
+
+static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
+
+static inline bool
+pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
+{
+	if (a.rti != b.rti)
+		return false;
+	if (a.plan_name == NULL)
+		return (b.plan_name == NULL);
+	if (b.plan_name == NULL)
+		return false;
+	return strcmp(a.plan_name, b.plan_name) == 0;
+}
+
+#define SH_PREFIX			pgpa_ri_check
+#define SH_ELEMENT_TYPE		pgpa_ri_checker
+#define SH_KEY_TYPE			pgpa_ri_checker_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#endif
+
+typedef struct pgpa_planner_state
+{
+	ExplainState *explain_state;
+	pgpa_trove *trove;
+	MemoryContext trove_cxt;
+
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_check_hash *ri_check_hash;
+#endif
+} pgpa_planner_state;
+
+typedef struct pgpa_join_state
+{
+	/* Most-recently-considered outer rel. */
+	RelOptInfo *outerrel;
+
+	/* Most-recently-considered inner rel. */
+	RelOptInfo *innerrel;
+
+	/*
+	 * Array of relation identifiers for all members of this joinrel, with
+	 * outerrel idenifiers before innerrel identifiers.
+	 */
+	pgpa_identifier *rids;
+
+	/* Number of outer rel identifiers. */
+	int			outer_count;
+
+	/* Number of inner rel identifiers. */
+	int			inner_count;
+
+	/*
+	 * Trove lookup results.
+	 *
+	 * join_entries and rel_entries are arrays of entries, and join_indexes
+	 * and rel_indexes are the integer offsets within those arrays of entries
+	 * potentially relevant to us. The "join" fields correspond to a lookup
+	 * using PGPA_TROVE_LOOKUP_JOIN and the "rel" fields to a lookup using
+	 * PGPA_TROVE_LOOKUP_REL.
+	 */
+	pgpa_trove_entry *join_entries;
+	Bitmapset  *join_indexes;
+	pgpa_trove_entry *rel_entries;
+	Bitmapset  *rel_indexes;
+} pgpa_join_state;
+
+/* Saved hook values */
+static get_relation_info_hook_type prev_get_relation_info = NULL;
+static join_path_setup_hook_type prev_join_path_setup = NULL;
+static joinrel_setup_hook_type prev_joinrel_setup = NULL;
+static planner_setup_hook_type prev_planner_setup = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+
+/* Other global variabes */
+static int	planner_extension_id = -1;
+
+/* Function prototypes. */
+static void pgpa_get_relation_info(PlannerInfo *root,
+								   Oid relationObjectId,
+								   bool inhparent,
+								   RelOptInfo *rel);
+static void pgpa_joinrel_setup(PlannerInfo *root,
+							   RelOptInfo *joinrel,
+							   RelOptInfo *outerrel,
+							   RelOptInfo *innerrel,
+							   SpecialJoinInfo *sjinfo,
+							   List *restrictlist);
+static void pgpa_join_path_setup(PlannerInfo *root,
+								 RelOptInfo *joinrel,
+								 RelOptInfo *outerrel,
+								 RelOptInfo *innerrel,
+								 JoinType jointype,
+								 JoinPathExtraData *extra);
+static void pgpa_planner_setup(PlannerGlobal *glob, Query *parse,
+							   const char *query_string,
+							   double *tuple_fraction,
+							   ExplainState *es);
+static void pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string, PlannedStmt *pstmt);
+static void pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p,
+											  char *plan_name,
+											  pgpa_join_state *pjs);
+static void pgpa_planner_apply_join_path_advice(JoinType jointype,
+												uint64 *pgs_mask_p,
+												char *plan_name,
+												pgpa_join_state *pjs);
+static void pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+										   pgpa_trove_entry *scan_entries,
+										   Bitmapset *scan_indexes,
+										   pgpa_trove_entry *rel_entries,
+										   Bitmapset *rel_indexes);
+static uint64 pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag);
+static bool pgpa_join_order_permits_join(int outer_count, int inner_count,
+										 pgpa_identifier *rids,
+										 pgpa_trove_entry *entry);
+static bool pgpa_join_method_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+
+static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+										  pgpa_trove_lookup_type type,
+										  pgpa_identifier *rt_identifiers,
+										  pgpa_plan_walker_context *walker);
+
+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);
+
+/*
+ * Install planner-related hooks.
+ */
+void
+pgpa_planner_install_hooks(void)
+{
+	planner_extension_id = GetPlannerExtensionId("pg_plan_advice");
+	prev_get_relation_info = get_relation_info_hook;
+	get_relation_info_hook = pgpa_get_relation_info;
+	prev_joinrel_setup = joinrel_setup_hook;
+	joinrel_setup_hook = pgpa_joinrel_setup;
+	prev_join_path_setup = join_path_setup_hook;
+	join_path_setup_hook = pgpa_join_path_setup;
+	prev_planner_setup = planner_setup_hook;
+	planner_setup_hook = pgpa_planner_setup;
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgpa_planner_shutdown;
+}
+
+/*
+ * Hook function for get_relation_info().
+ *
+ * We can apply scan advice at this opint, and we also usee this as an
+ * opportunity to do range-table identifier cross-checking in assert-enabled
+ * builds.
+ *
+ * XXX: We currently emit useless advice like NO_GATHER("*RESULT*") for trivial
+ * queries. The advice is useless because get_relation_info isn't called for
+ * non-relation RTEs. We should either suppress the advice in such cases, or
+ * add a hook that can apply it.
+ */
+static void
+pgpa_get_relation_info(PlannerInfo *root, Oid relationObjectId,
+					   bool inhparent, RelOptInfo *rel)
+{
+	pgpa_planner_state *pps;
+
+	/* 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. */
+	if (pps != NULL)
+		pgpa_ri_checker_save(pps, root, rel);
+
+	/* If query advice was provided, search for relevant entries. */
+	if (pps != NULL && pps->trove != NULL)
+	{
+		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,
+						  &tresult_scan);
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+						  &tresult_rel);
+
+		/* If relevant entries were found, apply them. */
+		if (tresult_scan.indexes != NULL || tresult_rel.indexes != NULL)
+			pgpa_planner_apply_scan_advice(rel,
+										   tresult_scan.entries,
+										   tresult_scan.indexes,
+										   tresult_rel.entries,
+										   tresult_rel.indexes);
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_get_relation_info)
+		(*prev_get_relation_info) (root, relationObjectId, inhparent, rel);
+}
+
+/*
+ * Search for advice pertaining to a proposed join.
+ */
+static pgpa_join_state *
+pgpa_get_join_state(PlannerInfo *root, RelOptInfo *joinrel,
+					RelOptInfo *outerrel, RelOptInfo *innerrel)
+{
+	pgpa_planner_state *pps;
+	pgpa_join_state *pjs;
+	bool		new_pjs = false;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+	if (pps == NULL || pps->trove == NULL)
+	{
+		/* No advice applies to this query, hence none to this joinrel. */
+		return NULL;
+	}
+
+	/*
+	 * See whether we've previously associated a pgpa_join_state with this
+	 * joinrel. If we have not, we need to try to construct one. If we have,
+	 * then there are two cases: (a) if innerrel and outerrel are unchanged,
+	 * we can simply use it, and (b) if they have changed, we need to rejigger
+	 * the array of identifiers but can still skip the trove lookup.
+	 */
+	pjs = GetRelOptInfoExtensionState(joinrel, planner_extension_id);
+	if (pjs != NULL)
+	{
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+		{
+			/*
+			 * If there's no potentially relevant advice, then the presence of
+			 * this pgpa_join_state acts like a negative cache entry: it tells
+			 * us not to bother searching the trove for advice, because we
+			 * will not find any.
+			 */
+			return NULL;
+		}
+
+		if (pjs->outerrel == outerrel && pjs->innerrel == innerrel)
+		{
+			/* No updates required, so just return. */
+			/* XXX. Does this need to do something different under GEQO? */
+			return pjs;
+		}
+	}
+
+	/*
+	 * If there's no pgpa_join_state yet, we need to allocate one. Trove keys
+	 * will not get built for RTE_JOIN RTEs, so the array may end up being
+	 * larger than needed. It's not worth trying to compute a perfectly
+	 * accurate count here.
+	 */
+	if (pjs == NULL)
+	{
+		int			pessimistic_count = bms_num_members(joinrel->relids);
+
+		pjs = palloc0_object(pgpa_join_state);
+		pjs->rids = palloc_array(pgpa_identifier, pessimistic_count);
+		new_pjs = true;
+	}
+
+	/*
+	 * Either we just allocated a new pgpa_join_state, or the existing one
+	 * needs reconfiguring for a new innerrel and outerrel. The required array
+	 * size can't change, so we can overwrite the existing one.
+	 */
+	pjs->outerrel = outerrel;
+	pjs->innerrel = innerrel;
+	pjs->outer_count =
+		pgpa_compute_identifiers_by_relids(root, outerrel->relids, pjs->rids);
+	pjs->inner_count =
+		pgpa_compute_identifiers_by_relids(root, innerrel->relids,
+										   pjs->rids + pjs->outer_count);
+
+	/*
+	 * If we allocated a new pgpa_join_state, search our trove of advice for
+	 * relevant entries. The trove lookup will return the same results for
+	 * every outerrel/innerrel combination, so we don't need to repeat that
+	 * work every time.
+	 */
+	if (new_pjs)
+	{
+		pgpa_trove_result tresult;
+
+		/* Find join entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_JOIN,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->join_entries = tresult.entries;
+		pjs->join_indexes = tresult.indexes;
+
+		/* Find rel entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->rel_entries = tresult.entries;
+		pjs->rel_indexes = tresult.indexes;
+
+		/* Now that the new pgpa_join_state is fully valid, save a pointer. */
+		SetRelOptInfoExtensionState(joinrel, planner_extension_id, pjs);
+
+		/*
+		 * If there was no relevant advice found, just return NULL. This
+		 * pgpa_join_state will stick around as a sort of negative cache
+		 * entry, so that future calls for this same joinrel quickly return
+		 * NULL.
+		 */
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+			return NULL;
+	}
+
+	return pjs;
+}
+
+/*
+ * Enforce any provided advice that is relevant to any method of implementing
+ * this join.
+ *
+ * Although we're passed the outerrel and innerrel here, those are just
+ * whatever values happened to prompt the creation of this joinrel; they
+ * shouldn't really influence our choice of what advice to apply.
+ */
+static void
+pgpa_joinrel_setup(PlannerInfo *root, RelOptInfo *joinrel,
+				   RelOptInfo *outerrel, RelOptInfo *innerrel,
+				   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_joinrel_advice(&joinrel->pgs_mask,
+										  root->plan_name,
+										  pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_joinrel_setup)
+		(*prev_joinrel_setup) (root, joinrel, outerrel, innerrel,
+							   sjinfo, restrictlist);
+}
+
+/*
+ * Enforce any provided advice that is relevant to this particular method of
+ * implementing this particular join.
+ */
+static void
+pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
+					 RelOptInfo *outerrel, RelOptInfo *innerrel,
+					 JoinType jointype, JoinPathExtraData *extra)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_join_path_advice(jointype,
+											&extra->pgs_mask,
+											root->plan_name,
+											pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_join_path_setup)
+		(*prev_join_path_setup) (root, joinrel, outerrel, innerrel,
+								 jointype, extra);
+}
+
+/*
+ * Prepare advice for use by a query.
+ */
+static void
+pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
+				   double *tuple_fraction, ExplainState *es)
+{
+	pgpa_trove *trove = NULL;
+	pgpa_planner_state *pps;
+	char	   *error;
+	bool		needs_pps = false;
+
+	/*
+	 * If any advice was provided, build a trove of advice for use during
+	 * planning.
+	 */
+	if (pg_plan_advice_advice != NULL && pg_plan_advice_advice[0] != '\0')
+	{
+		List	   *advice_items;
+
+		/*
+		 * Parsing shouldn't fail here, because we must have previously parsed
+		 * successfully in pg_plan_advice_advice_check_hook, but if it does,
+		 * emit a warning.
+		 */
+		advice_items = pgpa_parse(pg_plan_advice_advice, &error);
+		if (error)
+			elog(WARNING, "could not parse advice: %s", error);
+
+		/*
+		 * It's possible that the advice string was non-empty but contained no
+		 * actual advice, e.g. it was all whitespace.
+		 */
+		if (advice_items != NIL)
+		{
+			trove = pgpa_build_trove(advice_items);
+			needs_pps = true;
+		}
+	}
+
+#ifdef USE_ASSERT_CHECKING
+
+	/*
+	 * If asserts are enabled, always build a private state object for
+	 * cross-checks.
+	 */
+	needs_pps = true;
+#endif
+
+	/* Initialize and store private state, if required. */
+	if (needs_pps)
+	{
+		pps = palloc0_object(pgpa_planner_state);
+		pps->explain_state = es;
+		pps->trove = trove;
+#ifdef USE_ASSERT_CHECKING
+		pps->ri_check_hash =
+			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
+#endif
+		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
+	}
+}
+
+/*
+ * Carry out whatever work we want to do after planning is complete.
+ */
+static void
+pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	pgpa_planner_state *pps;
+	pgpa_trove *trove = NULL;
+	ExplainState *es = NULL;
+	pgpa_plan_walker_context walker = {0};	/* placate compiler */
+	bool		do_advice_feedback;
+	bool		do_collect_advice;
+	List	   *pgpa_items = NIL;
+	pgpa_identifier *rt_identifiers = NULL;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
+	if (pps != NULL)
+	{
+		trove = pps->trove;
+		es = pps->explain_state;
+	}
+
+	/* If at least one collector is enabled, generate advice. */
+	do_collect_advice = (pg_plan_advice_local_collection_limit > 0 ||
+						 pg_plan_advice_shared_collection_limit > 0);
+
+	/* If we applied advice, generate feedback. */
+	do_advice_feedback = (trove != NULL && es != NULL);
+
+	/* If either of the above apply, analyze the resulting PlannedStmt. */
+	if (do_collect_advice || do_advice_feedback)
+	{
+		pgpa_plan_walker(&walker, pstmt);
+		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+	}
+
+	/*
+	 * If advice collection is enabled, put the advice in string form and send
+	 * it to the collector.
+	 */
+	if (do_collect_advice)
+	{
+		char	   *advice_string;
+		StringInfoData buf;
+
+		/* Generate a textual advice string. */
+		initStringInfo(&buf);
+		pgpa_output_advice(&buf, &walker, rt_identifiers);
+		advice_string = buf.data;
+
+		/* If the advice string is empty, don't bother collecting it. */
+		if (advice_string[0] != '\0')
+			pgpa_collect_advice(pstmt->queryId, query_string, advice_string);
+
+		/*
+		 * If we've gone to the trouble of generating an advice string, and if
+		 * we're inside EXPLAIN, save the string so we don't need to
+		 * regenerate it.
+		 */
+		if (es != NULL)
+			pgpa_items = lappend(pgpa_items,
+								 makeDefElem("advice_string",
+											 (Node *) makeString(advice_string),
+											 -1));
+	}
+
+	/*
+	 * If we are planning within EXPLAIN, make arrangements to allow EXPLAIN
+	 * to tell the user what has happened with the provided advice.
+	 *
+	 * NB: If EXPLAIN is used on a prepared is a prepared statement, planning
+	 * will have already happened happened without recording these details. We
+	 * could consider adding a GUC to cater to that scenario; or we could do
+	 * this work all the time, but that seems like too much overhead.
+	 */
+	if (do_advice_feedback)
+	{
+		List	   *feedback = NIL;
+
+		/*
+		 * Inject a Node-tree representation of all the trove-entry flags into
+		 * the PlannedStmt.
+		 */
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_SCAN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_JOIN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_REL,
+												rt_identifiers, &walker);
+
+		pgpa_items = lappend(pgpa_items, makeDefElem("feedback",
+													 (Node *) feedback,
+													 -1));
+	}
+
+	/* Push whatever data we're saving into the PlannedStmt. */
+	if (pgpa_items != NIL)
+		pstmt->extension_state =
+			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);
+}
+
+/*
+ * Enforce overall restrictions on a join relation that apply uniformly
+ * regardless of the choice of inner and outer rel.
+ */
+static void
+pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
+								  pgpa_join_state *pjs)
+{
+	int			i = -1;
+	int			flags;
+	bool		gather_conflict = false;
+	uint64		gather_mask = 0;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	bool		partitionwise_conflict = false;
+	int			partitionwise_outcome = 0;
+	Bitmapset  *partitionwise_partial_match = NULL;
+	Bitmapset  *partitionwise_full_match = NULL;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type itm;
+		bool		full_match = false;
+		uint64		my_gather_mask = 0;
+		int			my_partitionwise_outcome = 0;	/* >0 yes, <0 no */
+
+		/*
+		 * For GATHER and GATHER_MERGE, if the specified relations exactly
+		 * match this joinrel, do whatever the advice says; otherwise, don't
+		 * allow Gather or Gather Merge at this level. For NO_GATHER, there
+		 * must be a single target relation which must be included in this
+		 * joinrel, so just don't allow Gather or Gather Merge here, full
+		 * stop.
+		 */
+		if (entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			full_match = true;
+		}
+		else
+		{
+			int			total_count;
+
+			total_count = pjs->outer_count + pjs->inner_count;
+			itm = pgpa_identifiers_match_target(total_count, pjs->rids,
+												entry->target);
+			Assert(itm != PGPA_ITM_DISJOINT);
+
+			if (itm == PGPA_ITM_EQUAL)
+			{
+				full_match = true;
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+					my_partitionwise_outcome = 1;
+				else if (entry->tag == PGPA_TAG_GATHER)
+					my_gather_mask = PGS_GATHER;
+				else if (entry->tag == PGPA_TAG_GATHER_MERGE)
+					my_gather_mask = PGS_GATHER_MERGE;
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+			else
+			{
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else if (entry->tag == PGPA_TAG_GATHER ||
+						 entry->tag == PGPA_TAG_GATHER_MERGE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (full_match)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+
+		/*
+		 * Likewise, if we set my_partitionwise_outcome up above, then we (1)
+		 * make a note if the advice conflicted, (2) remember what the desired
+		 * outcome was, and (3) remember whether this was a full or partial
+		 * match.
+		 */
+		if (my_partitionwise_outcome != 0)
+		{
+			if (partitionwise_outcome != 0 &&
+				partitionwise_outcome != my_partitionwise_outcome)
+				partitionwise_conflict = true;
+			partitionwise_outcome = my_partitionwise_outcome;
+			if (full_match)
+				partitionwise_full_match =
+					bms_add_member(partitionwise_full_match, i);
+			else
+				partitionwise_partial_match =
+					bms_add_member(partitionwise_partial_match, i);
+		}
+	}
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched, and if
+	 * the set of targets exactly matched this relation, fully matched. If
+	 * there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
+
+	/* Likewise for partitionwise advice. */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (partitionwise_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		*pgs_mask_p &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		*pgs_mask_p |= gather_mask;
+	}
+
+	/*
+	 * If there is a non-conflicting partitionwise specification, enforce.
+	 *
+	 * To force a partitionwise join, we disable all the ordinary means of
+	 * performing a join, and instead only Append and MergeAppend paths here.
+	 * To prevent one, we just disable Append and MergeAppend.  Note that we
+	 * must not unset PGS_CONSIDER_PARTITIONWISE even when we don't want a
+	 * partitionwise join here, because we might want one at a higher level
+	 * that is constructing using paths from this level.
+	 */
+	if (partitionwise_outcome != 0 && !partitionwise_conflict)
+	{
+		if (partitionwise_outcome > 0)
+			*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) |
+				PGS_APPEND | PGS_MERGE_APPEND | PGS_CONSIDER_PARTITIONWISE;
+		else
+			*pgs_mask_p &= ~(PGS_APPEND | PGS_MERGE_APPEND);
+	}
+}
+
+/*
+ * Enforce restrictions on the join order or join method.
+ *
+ * Note that, although it is possible to view PARTITIONWISE advice as
+ * controlling the join method, we can't enforce it here, because the code
+ * path where this executes only deals with join paths that are built directly
+ * from a single outer path and a single inner path.
+ */
+static void
+pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
+									char *plan_name,
+									pgpa_join_state *pjs)
+{
+	int			i = -1;
+	Bitmapset  *jo_permit_indexes = NULL;
+	Bitmapset  *jo_deny_indexes = NULL;
+	Bitmapset  *jm_indexes = NULL;
+	bool		jm_conflict = false;
+	uint32		join_mask = 0;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->join_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->join_entries[i];
+		uint32		my_join_mask;
+
+		/* Handle join order advice. */
+		if (entry->tag == PGPA_TAG_JOIN_ORDER)
+		{
+			if (pgpa_join_order_permits_join(pjs->outer_count,
+											 pjs->inner_count,
+											 pjs->rids,
+											 entry))
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			else
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			continue;
+		}
+
+		/* Handle join strategy advice. */
+		my_join_mask = pgpa_join_strategy_mask_from_advice_tag(entry->tag);
+		if (my_join_mask != 0)
+		{
+			bool		permit;
+			bool		restrict_method;
+
+			if (entry->tag == PGPA_TAG_FOREIGN_JOIN)
+				permit = pgpa_opaque_join_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			else
+				permit = pgpa_join_method_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			if (!permit)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				jm_indexes = bms_add_member(jo_permit_indexes, i);
+				if (join_mask != 0 && join_mask != my_join_mask)
+					jm_conflict = true;
+				join_mask = my_join_mask;
+			}
+			continue;
+		}
+
+		/* Handle semijoin uniqueness advice. */
+		if (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE ||
+			entry->tag == PGPA_TAG_SEMIJOIN_NON_UNIQUE)
+		{
+			bool		advice_unique;
+			bool		jt_unique;
+			bool		jt_non_unique;
+			bool		restrict_method;
+
+			/* Advice wants to unique-ify and use a regular join? */
+			advice_unique = (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE);
+
+			/* Planner is trying to unique-ify and use a regular join? */
+			jt_unique = (jointype == JOIN_UNIQUE_INNER ||
+						 jointype == JOIN_UNIQUE_OUTER);
+
+			/* Planner is trying a semi-join, without unique-ifying? */
+			jt_non_unique = (jointype == JOIN_SEMI ||
+							 jointype == JOIN_RIGHT_SEMI);
+
+			/*
+			 * These advice tags behave very much like join method advice, in
+			 * that they want the inner side of the semijoin to match the
+			 * relations listed in the advice. Hence, we test whether join
+			 * method advice would enforce a join order restriction here, and
+			 * disallow the join if not.
+			 *
+			 * XXX. Think harder about right semijoins.
+			 */
+			if (!pgpa_join_method_permits_join(pjs->outer_count,
+											   pjs->inner_count,
+											   pjs->rids,
+											   entry,
+											   &restrict_method))
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				if (!jt_unique && !jt_non_unique)
+				{
+					/*
+					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
+					 * or SJ_NON_UNIQUE can be applied.
+					 */
+					entry->flags |= PGPA_TE_INAPPLICABLE;
+				}
+				else if (advice_unique != jt_unique)
+					jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			}
+			continue;
+		}
+	}
+
+	/*
+	 * If the advice indicates both that this join order is permissible and
+	 * also that it isn't, then mark advice related to the join order as
+	 * conflicting.
+	 */
+	if (jo_permit_indexes != NULL && jo_deny_indexes != NULL)
+	{
+		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * If more than one join method specification is relevant here and they
+	 * differ, mark them all as conflicting.
+	 */
+	if (jm_conflict)
+		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
+							 PGPA_TE_CONFLICTING);
+
+	/*
+	 * If we were advised to deny this join order, then do so. However, if we
+	 * were also advised to permit it, then do nothing, since the advice
+	 * conflicts.
+	 */
+	if (jo_deny_indexes != NULL && jo_permit_indexes == NULL)
+		*pgs_mask_p = 0;
+
+	/*
+	 * If we were advised to restrict the join method, then do so. However, if
+	 * we got conflicting join method advice or were also advised to reject
+	 * this join order completely, then instead do nothing.
+	 */
+	if (join_mask != 0 && !jm_conflict && jo_deny_indexes == NULL)
+		*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) | join_mask;
+}
+
+/*
+ * Translate an advice tag into a path generation strategy mask.
+ *
+ * This function can be called with tag types that don't represent join
+ * strategies. In such cases, we just return 0, which can't be confused with
+ * a valid mask.
+ */
+static uint64
+pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag)
+{
+	switch (tag)
+	{
+		case PGPA_TAG_FOREIGN_JOIN:
+			return PGS_FOREIGNJOIN;
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return PGS_MERGEJOIN_PLAIN;
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return PGS_MERGEJOIN_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return PGS_NESTLOOP_PLAIN;
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return PGS_NESTLOOP_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return PGS_NESTLOOP_MEMOIZE;
+		case PGPA_TAG_HASH_JOIN:
+			return PGS_HASHJOIN;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Does a certain item of join order advice permit a certain join?
+ */
+static bool
+pgpa_join_order_permits_join(int outer_count, int inner_count,
+							 pgpa_identifier *rids,
+							 pgpa_trove_entry *entry)
+{
+	bool		loop = true;
+	bool		sublist = false;
+	int			length;
+	int			outer_length;
+	pgpa_advice_target *target = entry->target;
+	pgpa_advice_target *prefix_target;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * Find the innermost sublist that contains all keys; if no sublist does,
+	 * then continue processing with the toplevel list.
+	 *
+	 * For example, if the advice says JOIN_ORDER(t1 t2 (t3 t4 t5)), then we
+	 * should evaluate joins that only involve t3, t4, and/or t5 against the
+	 * (t3 t4 t5) sublist, and others against the full list.
+	 *
+	 * Note that (1) outermost sublist is always ordered and (2) whenever we
+	 * zoom into an unordered sublist, we instantly accept the proposed join.
+	 * If the advice says JOIN_ORDER(t1 t2 {t3 t4 t5}), any approach to
+	 * joining t3, t4, and/or t5 is acceptable.
+	 */
+	while (loop)
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+		loop = false;
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_itm_type itm;
+
+			if (child_target->ttype == PGPA_TARGET_IDENTIFIER)
+				continue;
+
+			itm = pgpa_identifiers_match_target(outer_count + inner_count,
+												rids, child_target);
+			if (itm == PGPA_ITM_EQUAL || itm == PGPA_ITM_KEYS_ARE_SUBSET)
+			{
+				if (child_target->ttype == PGPA_TARGET_ORDERED_LIST)
+				{
+					target = child_target;
+					sublist = true;
+					loop = true;
+					break;
+				}
+				else
+				{
+					Assert(child_target->ttype == PGPA_TARGET_UNORDERED_LIST);
+					return true;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Try to find a prefix of the selected join order list that is exactly
+	 * equal to the outer side of the proposed join.
+	 */
+	length = list_length(target->children);
+	prefix_target = palloc0_object(pgpa_advice_target);
+	prefix_target->ttype = PGPA_TARGET_ORDERED_LIST;
+	for (outer_length = 1; outer_length <= length; ++outer_length)
+	{
+		pgpa_itm_type itm;
+
+		/* Avoid leaking memory in every loop iteration. */
+		if (prefix_target->children != NULL)
+			list_free(prefix_target->children);
+		prefix_target->children = list_copy_head(target->children,
+												 outer_length);
+
+		/* Search, hoping to find an exact match. */
+		itm = pgpa_identifiers_match_target(outer_count, rids, prefix_target);
+		if (itm == PGPA_ITM_EQUAL)
+			break;
+
+		/*
+		 * If the prefix of the join order list that we're considering
+		 * includes some but not all of the outer rels, we can make the prefix
+		 * longer to find an exact match. But the advice hasn't mentioned
+		 * everything that's part of our outer rel yet, but has mentioned
+		 * things that are not, then this join doesn't match the join order
+		 * list.
+		 */
+		if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+			return false;
+	}
+
+	/*
+	 * If the previous looped stopped before the prefix_target included the
+	 * entire join order list, then the next member of the join order list
+	 * must exactly match the inner side of the join.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), if the outer side of the
+	 * current join includes only t1, then the inner side must be exactly t2;
+	 * if the outer side includes both t1 and t2, then the inner side must
+	 * include exactly t3, t4, and t5.
+	 */
+	if (outer_length < length)
+	{
+		pgpa_advice_target *inner_target;
+		pgpa_itm_type itm;
+
+		inner_target = list_nth(target->children, outer_length);
+
+		itm = pgpa_identifiers_match_target(inner_count, rids + outer_count,
+											inner_target);
+
+		/*
+		 * Before returning, consider whether we need to mark this entry as
+		 * fully matched. If we found every item but one on the lefthand side
+		 * of the join and the last item on the righthand side of the join,
+		 * then the answer is yes.
+		 */
+		if (outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
+			entry->flags |= PGPA_TE_MATCH_FULL;
+
+		return (itm == PGPA_ITM_EQUAL);
+	}
+
+	/*
+	 * If we get here, then the outer side of the join includes the entirety
+	 * of the join order list. In this case, we behave differently depending
+	 * on whether we're looking at the top-level join order list or sublist.
+	 * At the top-level, we treat the specified list as mandating that the
+	 * actual join order has the given list as a prefix, but a sublist
+	 * requires an exact match.
+	 *
+	 * Exmaple: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), we must start by joining
+	 * all five of those relations and in that sequence, but once that is
+	 * done, it's OK to join any other rels that are part of the join problem.
+	 * This allows a user to specify the driving table and perhaps the first
+	 * few things to which it should be joined while leaving the rest of the
+	 * join order up the optimizer. But it seems like it would be surprising,
+	 * given that specification, if the user could add t6 to the (t3 t4 t5)
+	 * sub-join, so we don't allow that. If we did want to allow it, the logic
+	 * earlier in this function would require substantial adjustment: we could
+	 * allow the t3-t4-t5-t6 join to be built here, but the next step of
+	 * joining t1-t2 to the result would still be rejected.
+	 */
+	return !sublist;
+}
+
+/*
+ * Does a certain item of join method advice permit a certain join?
+ *
+ * Advice such as HASH_JOIN((x y)) means that there should be a hash join with
+ * exactly x and y on the inner side. Obviously, this means that if we are
+ * considering a join with exactly x and y on the inner side, we should enforce
+ * the use of a hash join. However, it also means that we must reject some
+ * incompatible join orders entirely.  For example, a join with exactly x
+ * and y on the outer side shouldn't be allowed, because such paths might win
+ * over the advice-driven path on cost.
+ *
+ * To accommodate these requirements, this function returns true if the join
+ * should be allowed and false if it should not. Furthermore, *restrict_method
+ * is set to true if the join method should be enforced and false if not.
+ */
+static bool
+pgpa_join_method_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	/*
+	 * If our inner rel mentions exactly the same relations as the advice
+	 * target, allow the join and enforce the join method restriction.
+	 *
+	 * If our inner rel mentions a superset of the target relations, allow the
+	 * join. The join we care about has already taken place, and this advice
+	 * imposes no further restrictions.
+	 */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If our outer rel mentions a supserset of the relations in the advice
+	 * target, no restrictions apply. The join we care has already taken
+	 * place, and this advice imposes no further restrictions.
+	 *
+	 * On the other hand, if our outer rel mentions exactly the relations
+	 * mentioned in the advice target, the planner is trying to reverse the
+	 * sides of the join as compared with our desired outcome. Reject that.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+	else if (outer_itm == PGPA_ITM_EQUAL)
+		return false;
+
+	/*
+	 * If the advice target mentions only a single relation, the test below
+	 * cannot ever pass, so save some work by exiting now.
+	 */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+		return false;
+
+	/*
+	 * If everything in the joinrel is appears in the advice target, we're
+	 * below the level of the join we want to control.
+	 *
+	 * For example, HASH_JOIN((x y)) doesn't restrict how x and y can be
+	 * joined.
+	 *
+	 * This lookup shouldn't return PGPA_ITM_DISJOINT, because any such advice
+	 * should not have been returned from the trove in the first place.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've already permitted all allowable cases, so reject this.
+	 *
+	 * If we reach this point, then the advice overlaps with this join but
+	 * isn't entirely contained within either side, and there's also at least
+	 * one relation present in the join that isn't mentioned by the advice.
+	 *
+	 * For instance, in the HASH_JOIN((x y)) example, we would reach here if x
+	 * were on one side of the join, y on the other, and at least one of the
+	 * two sides also included some other relation, say t. In that case,
+	 * accepting this join would allow the (x y t) joinrel to contain
+	 * non-disabled paths that do not put (x y) on the inner side of a hash
+	 * join; we could instead end up with something like (x JOIN t) JOIN y.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning an opaque join permit a certain join?
+ *
+ * By an opaque join, we mean one where the exact mechanism by which the
+ * join is performed is not visible to PostgreSQL. Currently this is the
+ * case only for foreign joins: FOREIGN_JOIN((x y z)) means that x, y, and
+ * z are joined on the remote side, but we know nothing about the join order
+ * or join methods used over there.
+ */
+static bool
+pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	if (join_itm == PGPA_ITM_EQUAL)
+	{
+		/*
+		 * We have an exact match, and should therefore allow the join and
+		 * enforce the use of the relevant opaque join method.
+		 */
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+	{
+		/*
+		 * If join_itm == PGPA_ITM_TARGETS_ARE_SUBSET, then the join we care
+		 * about has already taken place and no further restrictions apply.
+		 *
+		 * If join_itm == PGPA_ITM_KEYS_ARE_SUBSET, we're still building up to
+		 * the join we care about and have not introduced any extraneous
+		 * relations not named in the advice. Note that ForeignScan paths for
+		 * joins are built up from ForeignScan paths from underlying joins and
+		 * scans, so we must not disable this join when considering a subset
+		 * of the relations we ultimately want.
+		 */
+		return true;
+	}
+
+	/*
+	 * The advice overlaps the join, but at least one relation is present in
+	 * the join that isn't mentioned by the advice. We want to disable such
+	 * paths so that we actually push down the join as intended.
+	 */
+	return false;
+}
+
+/*
+ * Apply scan advice to a RelOptInfo.
+ *
+ * XXX. For bitmap heap scans, we're just ignoring the index information from
+ * the advice. That's not cool.
+ */
+static void
+pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+							   pgpa_trove_entry *scan_entries,
+							   Bitmapset *scan_indexes,
+							   pgpa_trove_entry *rel_entries,
+							   Bitmapset *rel_indexes)
+{
+	bool		gather_conflict = false;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	int			i = -1;
+	pgpa_trove_entry *scan_entry = NULL;
+	int			flags;
+	bool		scan_type_conflict = false;
+	Bitmapset  *scan_type_indexes = NULL;
+	Bitmapset  *scan_type_rel_indexes = NULL;
+	uint64		gather_mask = 0;
+	uint64		scan_type = 0;
+
+	/* 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;
+
+		/* Translate our advice tags to a scan strategy advice value. */
+		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+			my_scan_type = PGS_BITMAPSCAN;
+		else if (my_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN)
+			my_scan_type = PGS_INDEXONLYSCAN | PGS_CONSIDER_INDEXONLY;
+		else if (my_entry->tag == PGPA_TAG_INDEX_SCAN)
+			my_scan_type = PGS_INDEXSCAN;
+		else if (my_entry->tag == PGPA_TAG_SEQ_SCAN)
+			my_scan_type = PGS_SEQSCAN;
+		else if (my_entry->tag == PGPA_TAG_TID_SCAN)
+			my_scan_type = PGS_TIDSCAN;
+
+		/*
+		 * If this is understandable scan advice, hang on to the entry, the
+		 * inferred scan type type, and the index at which we found it.
+		 *
+		 * Also make a note if we see conflicting scan type advice. Note that
+		 * we regard two index specifications as conflicting unless they match
+		 * exactly. In theory, perhaps we could regard INDEX_SCAN(a c) and
+		 * 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 (scan_type != 0 && scan_type != my_scan_type)
+				scan_type_conflict = true;
+			if (!scan_type_conflict && scan_entry != NULL &&
+				my_entry->target->itarget != NULL &&
+				scan_entry->target->itarget != NULL &&
+				!pgpa_index_targets_equal(scan_entry->target->itarget,
+										  my_entry->target->itarget))
+				scan_type_conflict = true;
+			scan_entry = my_entry;
+			scan_type = my_scan_type;
+			scan_type_indexes = bms_add_member(scan_type_indexes, i);
+		}
+	}
+
+	/* Scrutinize available gather-related and partitionwise advice. */
+	i = -1;
+	while ((i = bms_next_member(rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *my_entry = &rel_entries[i];
+		uint64		my_gather_mask = 0;
+		bool		just_one_rel;
+
+		just_one_rel = my_entry->target->ttype == PGPA_TARGET_IDENTIFIER
+			|| list_length(my_entry->target->children) == 1;
+
+		/*
+		 * PARTITIONWISE behaves like a scan type, except that if there's more
+		 * than one relation targeted, it has no effect at this level.
+		 */
+		if (my_entry->tag == PGPA_TAG_PARTITIONWISE)
+		{
+			if (just_one_rel)
+			{
+				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
+
+				if (scan_type != 0 && scan_type != my_scan_type)
+					scan_type_conflict = true;
+				scan_entry = my_entry;
+				scan_type = my_scan_type;
+				scan_type_rel_indexes =
+					bms_add_member(scan_type_rel_indexes, i);
+			}
+			continue;
+		}
+
+		/*
+		 * GATHER and GATHER_MERGE applied to a single rel mean that we should
+		 * use the correspondings strategy here, while applying either to more
+		 * than one rel means we should not use those strategies here, but
+		 * rather at the level of the joinrel that corresponds to what was
+		 * specified. NO_GATHER can only be applied to single rels.
+		 *
+		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
+		 * equivalent to allowing the non-use of either form of Gather here.
+		 */
+		if (my_entry->tag == PGPA_TAG_GATHER ||
+			my_entry->tag == PGPA_TAG_GATHER_MERGE)
+		{
+			if (!just_one_rel)
+				my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			else if (my_entry->tag == PGPA_TAG_GATHER)
+				my_gather_mask = PGS_GATHER;
+			else
+				my_gather_mask = PGS_GATHER_MERGE;
+		}
+		else if (my_entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			Assert(just_one_rel);
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (just_one_rel)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+	}
+
+	/* Enforce choice of index. */
+	if (scan_entry != NULL && !scan_type_conflict &&
+		(scan_entry->tag == PGPA_TAG_INDEX_SCAN ||
+		 scan_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN))
+	{
+		pgpa_index_target *itarget = scan_entry->target->itarget;
+		IndexOptInfo *matched_index = NULL;
+
+		Assert(itarget->itype == PGPA_INDEX_NAME);
+
+		foreach_node(IndexOptInfo, index, rel->indexlist)
+		{
+			char	   *relname = get_rel_name(index->indexoid);
+			Oid			nspoid = get_rel_namespace(index->indexoid);
+			char	   *relnamespace = get_namespace_name(nspoid);
+
+			if (strcmp(itarget->indname, relname) == 0 &&
+				(itarget->indnamespace == NULL ||
+				 strcmp(itarget->indnamespace, relnamespace) == 0))
+			{
+				matched_index = index;
+				break;
+			}
+		}
+
+		if (matched_index == NULL)
+		{
+			/* Don't force the scan type if the index doesn't exist. */
+			scan_type = 0;
+
+			/* Mark advice as inapplicable. */
+			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
+								 PGPA_TE_INAPPLICABLE);
+		}
+		else
+		{
+			/* Retain this index and discard the rest. */
+			rel->indexlist = list_make1(matched_index);
+		}
+	}
+
+	/*
+	 * Mark all the scan method entries as fully matched; and if they specify
+	 * different things, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	if (scan_type_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
+	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched. Mark
+	 * the ones that included this relation as a target by itself as fully
+	 * matched. If there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
+
+	/* If there is a non-conflicting scan specification, enforce it. */
+	if (scan_type != 0 && !scan_type_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
+			  PGS_CONSIDER_INDEXONLY);
+		rel->pgs_mask |= scan_type;
+	}
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		rel->pgs_mask |= gather_mask;
+	}
+}
+
+/*
+ * Add feedback entries to for one trove slice to the provided list and
+ * return the resulting list.
+ *
+ * Feedback entries are generated from the trove entry's flags. It's assumed
+ * that the caller has already set all relevant flags with the exception of
+ * PGPA_TE_FAILED. We set that flag here if appropriate.
+ */
+static List *
+pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+							 pgpa_trove_lookup_type type,
+							 pgpa_identifier *rt_identifiers,
+							 pgpa_plan_walker_context *walker)
+{
+	pgpa_trove_entry *entries;
+	int			nentries;
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	pgpa_trove_lookup_all(trove, type, &entries, &nentries);
+	for (int i = 0; i < nentries; ++i)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+		DefElem    *item;
+
+		/*
+		 * If this entry was fully matched, check whether generating advice
+		 * from this plan would produce such an entry. If not, label the entry
+		 * as failed.
+		 */
+		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+			!pgpa_walker_would_advise(walker, rt_identifiers,
+									  entry->tag, entry->target))
+			entry->flags |= PGPA_TE_FAILED;
+
+		item = makeDefElem(pgpa_cstring_trove_entry(entry),
+						   (Node *) makeInteger(entry->flags), -1);
+		list = lappend(list, item);
+	}
+
+	return list;
+}
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * Fast hash function for a key consisting of an RTI and plan name.
+ */
+static uint32
+pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	hs.accum = key.rti;
+	fasthash_combine(&hs);
+
+	/* plan_name can be NULL */
+	if (key.plan_name == NULL)
+		sp_len = 0;
+	else
+		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+
+	/* hashfn_unstable.h recommends using string length as tweak */
+	return fasthash_final32(&hs, sp_len);
+}
+
+#endif
+
+/*
+ * Save the range table identifier for one relation for future cross-checking.
+ */
+static void
+pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
+					 RelOptInfo *rel)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_checker_key key;
+	pgpa_ri_checker *check;
+	pgpa_identifier rid;
+	const char *rid_string;
+	bool		found;
+
+	key.rti = bms_singleton_member(rel->relids);
+	key.plan_name = root->plan_name;
+	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
+	rid_string = pgpa_identifier_string(&rid);
+	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
+	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
+	check->rid_string = rid_string;
+#endif
+}
+
+/*
+ * 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_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	pgpa_ri_check_iterator it;
+	pgpa_ri_checker *check;
+
+	/* 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. */
+	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
+	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	{
+		int			rtoffset = 0;
+		const char *rid_string;
+		Index		flat_rti;
+
+		/*
+		 * 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.
+		 */
+		if (check->key.plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				/*
+				 * If rtinfo->dummy is set, then the subquery's range table
+				 * will only have been partially copied to the final range
+				 * table. Specifically, only RTE_RELATION entries and
+				 * 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
+				 * in the final rtable.
+				 *
+				 * With more complex logic, we could work around that problem
+				 * by remembering the whole contents of the subquery's rtable
+				 * during planning, determining which of those would have been
+				 * copied to the final rtable, and matching them up. But it
+				 * doesn't seem like a worthwhile endeavor for right now,
+				 * because RTIs from such subqueries won't appear in the plan
+				 * tree itself, just in the range table. Hence, we can neither
+				 * generate nor accept advice for them.
+				 */
+				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+					&& !rtinfo->dummy)
+				{
+					rtoffset = rtinfo->rtoffset;
+					Assert(rtoffset > 0);
+					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;
+		}
+
+		/*
+		 * check->key.rti is the RTI that we saw prior to range-table
+		 * flattening, so we must add the appropriate RT offset to get the
+		 * final RTI.
+		 */
+		flat_rti = check->key.rti + rtoffset;
+		Assert(flat_rti <= list_length(pstmt->rtable));
+
+		/* Assert that the string we compute now matches the previous one. */
+		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
+		Assert(strcmp(rid_string, check->rid_string) == 0);
+	}
+#endif
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
new file mode 100644
index 00000000000..7d40b910b00
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -0,0 +1,17 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.h
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_PLANNER_H
+#define PGPA_PLANNER_H
+
+extern void pgpa_planner_install_hooks(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
new file mode 100644
index 00000000000..dbd7c99e4c2
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -0,0 +1,278 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.c
+ *	  analysis of scans in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+
+static pgpa_scan *pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								 pgpa_scan_strategy strategy,
+								 Bitmapset *relids,
+								 bool beneath_any_gather);
+
+
+static Bitmapset *filter_out_join_relids(Bitmapset *relids, List *rtable);
+static RTEKind unique_nonjoin_rtekind(Bitmapset *relids, List *rtable);
+
+/*
+ * Build a pgpa_scan object for a Plan node and update the plan walker
+ * context as appopriate.  If this is an Append or MergeAppend scan, also
+ * build pgpa_scan for any scans that were consolidated into this one by
+ * Append/MergeAppend pull-up.
+ *
+ * If there is at least one ElidedNode for this plan node, pass the uppermost
+ * one as elided_node, else pass NULL.
+ *
+ * Set the 'beneath_any_gather' node if we are underneath a Gather or
+ * Gather Merge node.
+ *
+ * Set the 'within_join_problem' flag if we're inside of a join problem and
+ * not otherwise.
+ */
+pgpa_scan *
+pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+				ElidedNode *elided_node,
+				bool beneath_any_gather, bool within_join_problem)
+{
+	pgpa_scan_strategy strategy = PGPA_SCAN_ORDINARY;
+	Bitmapset  *relids = NULL;
+	int			rti = -1;
+	List	   *child_append_relid_sets = NIL;
+
+	if (elided_node != NULL)
+	{
+		NodeTag		elided_type = elided_node->elided_type;
+
+		/*
+		 * If setrefs processing elided an Append or MergeAppend node that had
+		 * only one surviving child, then this is a partitionwise "scan" --
+		 * which may really be a partitionwise join, but there's no need to
+		 * distinguish.
+		 *
+		 * If it's a trivial SubqueryScan that was elided, then this is an
+		 * "ordinary" scan i.e. one for which we need to generate advice
+		 * because the planner has not made any meaningful choice.
+		 */
+		relids = elided_node->relids;
+		if (elided_type == T_Append || elided_type == T_MergeAppend)
+			strategy = PGPA_SCAN_PARTITIONWISE;
+		else
+			strategy = PGPA_SCAN_ORDINARY;
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+	{
+		relids = bms_make_singleton(rti);
+
+		switch (nodeTag(plan))
+		{
+			case T_SeqScan:
+				strategy = PGPA_SCAN_SEQ;
+				break;
+			case T_BitmapHeapScan:
+				strategy = PGPA_SCAN_BITMAP_HEAP;
+				break;
+			case T_IndexScan:
+				strategy = PGPA_SCAN_INDEX;
+				break;
+			case T_IndexOnlyScan:
+				strategy = PGPA_SCAN_INDEX_ONLY;
+				break;
+			case T_TidScan:
+			case T_TidRangeScan:
+				strategy = PGPA_SCAN_TID;
+				break;
+			default:
+
+				/*
+				 * This case includes a ForeignScan targeting a single
+				 * relation; no other strategy is possible in that case, but
+				 * see below, where things are different in multi-relation
+				 * cases.
+				 */
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+	}
+	else if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		switch (nodeTag(plan))
+		{
+			case T_ForeignScan:
+
+				/*
+				 * If multiple relations are being targeted by a single
+				 * foreign scan, then the foreign join has been pushed to the
+				 * remote side, and we want that to be reflected in the
+				 * generated advice.
+				 */
+				strategy = PGPA_SCAN_FOREIGN;
+				break;
+			case T_Append:
+
+				/*
+				 * Append nodes can represent partitionwise scans of a a
+				 * relation, but when they implement a set operation, they are
+				 * just ordinary scans.
+				 */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((Append *) plan)->child_append_relid_sets;
+				break;
+			case T_MergeAppend:
+				/* Some logic here as for Append, above. */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((MergeAppend *) plan)->child_append_relid_sets;
+				break;
+			default:
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+
+	/*
+	 * If this is an Append or MergeAppend node into which subordinate Append
+	 * or MergeAppend paths were merged, each of those merged paths is
+	 * effectively another scan for which we need to account.
+	 */
+	foreach_node(Bitmapset, child_relids, child_append_relid_sets)
+	{
+		Bitmapset  *child_nonjoin_relids;
+
+		child_nonjoin_relids = filter_out_join_relids(child_relids,
+													  walker->pstmt->rtable);
+		(void) pgpa_make_scan(walker, plan, strategy,
+							  child_nonjoin_relids,
+							  beneath_any_gather);
+	}
+
+	/*
+	 * If this plan node has no associated RTIs, it's not a scan. When the
+	 * 'within_join_problem' flag is set, that's unexpected, so throw an
+	 * error, else return quietly.
+	 */
+	if (relids == NULL)
+	{
+		if (within_join_problem)
+			elog(ERROR, "plan node has no RTIs: %d", (int) nodeTag(plan));
+		return NULL;
+	}
+
+	return pgpa_make_scan(walker, plan, strategy, relids, beneath_any_gather);
+}
+
+/*
+ * Create a single pgpa_scan object and update the pgpa_plan_walker_context.
+ */
+static pgpa_scan *
+pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+			   pgpa_scan_strategy strategy, Bitmapset *relids,
+			   bool beneath_any_gather)
+{
+	pgpa_scan  *scan;
+
+	/* Create the scan object. */
+	scan = palloc(sizeof(pgpa_scan));
+	scan->plan = plan;
+	scan->strategy = strategy;
+	scan->relids = relids;
+	scan->beneath_any_gather = beneath_any_gather;
+
+	/* Add it to the appropriate list. */
+	walker->scans[scan->strategy] = lappend(walker->scans[scan->strategy],
+											scan);
+
+	/*
+	 * We intend to emit NO_GATHER() advice for each scan that doesn't appear
+	 * beneath a Gather or Gather Merge node, but we need not do this for
+	 * partitionwise scans, because emitting NO_GATHER() for the child scans
+	 * suffices.
+	 */
+	if (!scan->beneath_any_gather && scan->strategy != PGPA_SCAN_PARTITIONWISE)
+		walker->no_gather_scans = bms_add_members(walker->no_gather_scans,
+												  scan->relids);
+
+	return scan;
+}
+
+/*
+ * Determine the unique rtekind of a set of relids.
+ */
+static RTEKind
+unique_nonjoin_rtekind(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	bool		first = true;
+	RTEKind		rtekind;
+
+	Assert(relids != NULL);
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		if (first)
+		{
+			rtekind = rte->rtekind;
+			first = false;
+		}
+		else if (rtekind != rte->rtekind)
+			elog(ERROR, "rtekind mismatch: %d vs. %d",
+				 rtekind, rte->rtekind);
+	}
+
+	if (first)
+		elog(ERROR, "no non-RTE_JOIN RTEs found");
+
+	return rtekind;
+}
+
+/*
+ * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
+ */
+static Bitmapset *
+filter_out_join_relids(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	Bitmapset  *result = NULL;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind != RTE_JOIN)
+			result = bms_add_member(result, rti);
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_scan.h b/contrib/pg_plan_advice/pgpa_scan.h
new file mode 100644
index 00000000000..90a08b41c5b
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.h
@@ -0,0 +1,86 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.h
+ *	  analysis of scans in Plan trees
+ *
+ * For purposes of this module, a "scan" includes (1) single plan nodes that
+ * scan multiple RTIs, such as a degenerate Result node that replaces what
+ * would otherwise have been a join, and (2) Append and MergeAppend nodes
+ * implementing a partitionwise scan or a partitionwise join. Said
+ * differently, scans are the leaves of the join tree for a single join
+ * problem.
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_SCAN_H
+#define PGPA_SCAN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+
+/*
+ * Scan strategies.
+ *
+ * PGPA_SCAN_ORDINARY is any scan strategy that isn't interesting to us
+ * because there is no meaningful planner decision involved. For example,
+ * the only way to scan a subquery is a SubqueryScan, and the only way to
+ * scan a VALUES construct is a ValuesScan. We need not care exactly which
+ * type of planner node was used in such cases, because the same thing will
+ * happen when replanning.
+ *
+ * PGPA_SCAN_ORDINARY also includes Result nodes that correspond to scans
+ * or even joins that are proved empty. We don't know whether or not the scan
+ * or join will still be provably empty at replanning time, but if it is,
+ * then no scan-type advice is needed, and if it's not, we can't recommend
+ * a scan type based on the current plan.
+ *
+ * PGPA_SCAN_PARTITIONWISE also lumps together scans and joins: this can
+ * be either a partitionwise scan of a partitioned table or a partitionwise
+ * join between several partitioned tables. Note that all decisions about
+ * whether or not to use partitionwise join are meaningful: no matter what
+ * we decided this time, we could do more or fewer things partitionwise the
+ * next time.
+ *
+ * PGPA_SCAN_FOREIGN is only used when there's more than one relation involved;
+ * a single-table foreign scan is classified as ordinary, since there is no
+ * decision to make in that case.
+ *
+ * Other scan strategies map one-to-one to plan nodes.
+ */
+typedef enum
+{
+	PGPA_SCAN_ORDINARY = 0,
+	PGPA_SCAN_SEQ,
+	PGPA_SCAN_BITMAP_HEAP,
+	PGPA_SCAN_FOREIGN,
+	PGPA_SCAN_INDEX,
+	PGPA_SCAN_INDEX_ONLY,
+	PGPA_SCAN_PARTITIONWISE,
+	PGPA_SCAN_TID
+	/* update NUM_PGPA_SCAN_STRATEGY if you add anything here */
+} pgpa_scan_strategy;
+
+#define NUM_PGPA_SCAN_STRATEGY	((int) PGPA_SCAN_TID + 1)
+
+/*
+ * All of the details we need regarding a scan.
+ */
+typedef struct pgpa_scan
+{
+	Plan	   *plan;
+	pgpa_scan_strategy strategy;
+	Bitmapset  *relids;
+	bool		beneath_any_gather;
+} pgpa_scan;
+
+extern pgpa_scan *pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								  ElidedNode *elided_node,
+								  bool beneath_any_gather,
+								  bool within_join_problem);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scanner.l b/contrib/pg_plan_advice/pgpa_scanner.l
new file mode 100644
index 00000000000..be7d7ba13a6
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scanner.l
@@ -0,0 +1,299 @@
+%top{
+/*
+ * Scanner for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_scanner.l
+ */
+#include "postgres.h"
+
+#include "common/string.h"
+#include "nodes/miscnodes.h"
+#include "parser/scansup.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Extra data that we pass around when during scanning.
+ *
+ * 'litbuf' is used to implement the <xd> exclusive state, which handles
+ * double-quoted identifiers.
+ */
+typedef struct pgpa_yy_extra_type
+{
+	StringInfoData	litbuf;
+} pgpa_yy_extra_type;
+
+}
+
+%{
+/* LCOV_EXCL_START */
+
+#define YY_DECL \
+	extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result, \
+						  char **parse_error_msg_p, yyscan_t yyscanner)
+
+/* No reason to constrain amount of data slurped */
+#define YY_READ_BUF_SIZE 16777216
+
+/* Avoid exit() on fatal scanner errors (a bit ugly -- see yy_fatal_error) */
+#undef fprintf
+#define fprintf(file, fmt, msg)  fprintf_to_ereport(fmt, msg)
+
+static void
+fprintf_to_ereport(const char *fmt, const char *msg)
+{
+	ereport(ERROR, (errmsg_internal("%s", msg)));
+}
+%}
+
+%option reentrant
+%option bison-bridge
+%option 8bit
+%option never-interactive
+%option nodefault
+%option noinput
+%option nounput
+%option noyywrap
+%option noyyalloc
+%option noyyrealloc
+%option noyyfree
+%option warn
+%option prefix="pgpa_yy"
+%option extra-type="pgpa_yy_extra_type *"
+
+/*
+ * What follows is a severely stripped-down version of the core scanner. We
+ * only care about recognizing identifiers with or without identifier quoting
+ * (i.e. double-quoting), decimal integers, and a small handful of other
+ * things. Keep these rules in sync with src/backend/parser/scan.l. As in that
+ * file, we use an exclusive state called 'xc' for C-style comments, and an
+ * exclusive state called 'xd' for double-quoted identifiers.
+ */
+%x xc
+%x xd
+
+ident_start		[A-Za-z\200-\377_]
+ident_cont		[A-Za-z\200-\377_0-9\$]
+
+identifier		{ident_start}{ident_cont}*
+
+decdigit		[0-9]
+decinteger		{decdigit}(_?{decdigit})*
+
+space			[ \t\n\r\f\v]
+whitespace		{space}+
+
+dquote			\"
+xdstart			{dquote}
+xdstop			{dquote}
+xddouble		{dquote}{dquote}
+xdinside		[^"]+
+
+xcstart			\/\*
+xcstop			\*+\/
+xcinside		[^*/]+
+
+%%
+
+{whitespace}	{ /* ignore */ }
+
+{identifier}	{
+					char   *str;
+					bool	fail;
+					pgpa_advice_tag_type	tag;
+
+					/*
+					 * Unlike the core scanner, we don't truncate identifiers
+					 * here. There is no obvious reason to do so.
+					 */
+					str = downcase_identifier(yytext, yyleng, false, false);
+					yylval->str = str;
+
+					/*
+					 * If it's not a tag, just return TOK_IDENT; else, return
+					 * a token type based on how further parsing should
+					 * proceed.
+					 */
+					tag = pgpa_parse_advice_tag(str, &fail);
+					if (fail)
+						return TOK_IDENT;
+					else if (tag == PGPA_TAG_JOIN_ORDER)
+						return TOK_TAG_JOIN_ORDER;
+					else if (tag == PGPA_TAG_INDEX_SCAN ||
+							 tag == PGPA_TAG_INDEX_ONLY_SCAN)
+						return TOK_TAG_INDEX;
+					else if (tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+						return TOK_TAG_BITMAP;
+					else if (tag == PGPA_TAG_SEQ_SCAN ||
+							 tag == PGPA_TAG_TID_SCAN ||
+							 tag == PGPA_TAG_NO_GATHER)
+						return TOK_TAG_SIMPLE;
+					else
+						return TOK_TAG_GENERIC;
+				}
+
+{decinteger}	{
+					char   *endptr;
+
+					errno = 0;
+					yylval->integer = strtoint(yytext, &endptr, 10);
+					if (*endptr != '\0' || errno == ERANGE)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "integer out of range");
+					return TOK_INTEGER;
+				}
+
+{xcstart}		{
+					BEGIN(xc);
+				}
+
+{xdstart}		{
+					BEGIN(xd);
+					resetStringInfo(&yyextra->litbuf);
+				}
+
+"||"			{ return TOK_OR; }
+
+"&&"			{ return TOK_AND; }
+
+.				{ return yytext[0]; }
+
+<xc>{xcstop}	{
+					BEGIN(INITIAL);
+				}
+
+<xc>{xcinside}	{
+					/* discard multiple characters without slash or asterisk */
+				}
+
+<xc>.			{
+					/*
+					 * Discard any single character. flex prefers longer
+					 * matches, so this rule will never be picked when we could
+					 * have matched xcstop.
+					 *
+					 * NB: At present, we don't bother to support nested
+					 * C-style comments here, but this logic could be extended
+					 * if that restriction poses a problem.
+					 */
+				}
+
+<xc><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated comment");
+				}
+
+<xd>{xdstop}	{
+					BEGIN(INITIAL);
+					yylval->str = pstrdup(yyextra->litbuf.data);
+					return TOK_IDENT;
+				}
+
+<xd>{xddouble}	{
+					appendStringInfoChar(&yyextra->litbuf, '"');
+				}
+
+<xd>{xdinside}	{
+					appendBinaryStringInfo(&yyextra->litbuf, yytext, yyleng);
+				}
+
+<xd><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated quoted identifier");
+				}
+
+%%
+
+/* LCOV_EXCL_STOP */
+
+/*
+ * Handler for errors while scanning or parsing advice.
+ *
+ * bison passes the error message to us via 'message', and the context is
+ * available via the 'yytext' macro. We assemble those values into a final
+ * error text and then arrange to pass it back to the caller of pgpa_yyparse()
+ * by storing it into *parse_error_msg_p.
+ */
+void
+pgpa_yyerror(List **result, char **parse_error_msg_p, yyscan_t yyscanner,
+			 const char *message)
+{
+	struct yyguts_t *yyg = (struct yyguts_t *) yyscanner;	/* needed for yytext
+															 * macro */
+
+
+	/* report only the first error in a parse operation */
+	if (*parse_error_msg_p)
+		return;
+
+	if (yytext[0])
+		*parse_error_msg_p = psprintf("%s at or near \"%s\"", message, yytext);
+	else
+		*parse_error_msg_p = psprintf("%s at end of input", message);
+}
+
+/*
+ * Initialize the advice scanner.
+ *
+ * This should be called before parsing begins.
+ */
+void
+pgpa_scanner_init(const char *str, yyscan_t *yyscannerp)
+{
+	yyscan_t	yyscanner;
+	pgpa_yy_extra_type	*yyext = palloc0_object(pgpa_yy_extra_type);
+
+	if (yylex_init(yyscannerp) != 0)
+		elog(ERROR, "yylex_init() failed: %m");
+
+	yyscanner = *yyscannerp;
+
+	initStringInfo(&yyext->litbuf);
+	pgpa_yyset_extra(yyext, yyscanner);
+
+	yy_scan_string(str, yyscanner);
+}
+
+
+/*
+ * Shut down the advice scanner.
+ *
+ * This should be called after parsing is complete.
+ */
+void
+pgpa_scanner_finish(yyscan_t yyscanner)
+{
+	yylex_destroy(yyscanner);
+}
+
+/*
+ * Interface functions to make flex use palloc() instead of malloc().
+ * It'd be better to make these static, but flex insists otherwise.
+ */
+
+void *
+yyalloc(yy_size_t size, yyscan_t yyscanner)
+{
+	return palloc(size);
+}
+
+void *
+yyrealloc(void *ptr, yy_size_t size, yyscan_t yyscanner)
+{
+	if (ptr)
+		return repalloc(ptr, size);
+	else
+		return palloc(size);
+}
+
+void
+yyfree(void *ptr, yyscan_t yyscanner)
+{
+	if (ptr)
+		pfree(ptr);
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
new file mode 100644
index 00000000000..a92121feb1d
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -0,0 +1,490 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.c
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * This name comes from the English expression "trove of advice", which
+ * means a collection of wisdom. This slightly unusual term is chosen to
+ * avoid naming confusion; for example, "collection of advice" would
+ * invite confusion with pgpa_collector.c. Note that, while we don't know
+ * whether the provided advice is actually wise, it's not our job to
+ * question the user's choices.
+ *
+ * The goal of this module is to make it easy to locate the specific
+ * bits of advice that pertain to any given part of a query, or to
+ * determine that there are none.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_trove.h"
+
+#include "common/hashfn_unstable.h"
+
+/*
+ * An advice trove is organized into a series of "slices", each of which
+ * contains information about one topic e.g. scan methods. Each slice consists
+ * of an array of trove entries plus a hash table that we can use to determine
+ * which ones are relevant to a particular part of the query.
+ */
+typedef struct pgpa_trove_slice
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	pgpa_trove_entry *entries;
+	struct pgpa_trove_entry_hash *hash;
+} pgpa_trove_slice;
+
+/*
+ * Scan advice is stored into 'scan'; join advice is stored into 'join'; and
+ * advice that can apply to both cases is stored into 'rel'. This lets callers
+ * ask just for what's relevant. These slices correspond to the possible values
+ * of pgpa_trove_lookup_type.
+ */
+struct pgpa_trove
+{
+	pgpa_trove_slice join;
+	pgpa_trove_slice rel;
+	pgpa_trove_slice scan;
+};
+
+/*
+ * We're going to build a hash table to allow clients of this module to find
+ * relevant advice for a given part of the query quickly. However, we're going
+ * to use only three of the five key fields as hash keys. There are two reasons
+ * for this.
+ *
+ * First, it's allowable to set partition_schema to NULL to match a partition
+ * with the correct name in any schema.
+ *
+ * Second, we expect the "occurrence" and "partition_schema" portions of the
+ * relation identifiers to be mostly uninteresting. Most of the time, the
+ * occurrence field will be 1 and the partition_schema values will all be the
+ * same. Even when there is some variation, the absolute number of entries
+ * that have the same values for all three of these key fields should be
+ * quite small.
+ */
+typedef struct
+{
+	const char *alias_name;
+	const char *partition_name;
+	const char *plan_name;
+} pgpa_trove_entry_key;
+
+typedef struct
+{
+	pgpa_trove_entry_key key;
+	int			status;
+	Bitmapset  *indexes;
+} pgpa_trove_entry_element;
+
+static uint32 pgpa_trove_entry_hash_key(pgpa_trove_entry_key key);
+
+static inline bool
+pgpa_trove_entry_compare_key(pgpa_trove_entry_key a, pgpa_trove_entry_key b)
+{
+	if (strcmp(a.alias_name, b.alias_name) != 0)
+		return false;
+
+	if (!strings_equal_or_both_null(a.partition_name, b.partition_name))
+		return false;
+
+	if (!strings_equal_or_both_null(a.plan_name, b.plan_name))
+		return false;
+
+	return true;
+}
+
+#define SH_PREFIX			pgpa_trove_entry
+#define SH_ELEMENT_TYPE		pgpa_trove_entry_element
+#define SH_KEY_TYPE			pgpa_trove_entry_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_trove_entry_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_trove_entry_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static void pgpa_init_trove_slice(pgpa_trove_slice *tslice);
+static void pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+									pgpa_advice_tag_type tag,
+									pgpa_advice_target *target);
+static void pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash,
+								   pgpa_advice_target *target,
+								   int index);
+static Bitmapset *pgpa_trove_slice_lookup(pgpa_trove_slice *tslice,
+										  pgpa_identifier *rid);
+
+/*
+ * Build a trove of advice from a list of advice items.
+ *
+ * Caller can obtain a list of advice items to pass to this function by
+ * calling pgpa_parse().
+ */
+pgpa_trove *
+pgpa_build_trove(List *advice_items)
+{
+	pgpa_trove *trove = palloc_object(pgpa_trove);
+
+	pgpa_init_trove_slice(&trove->join);
+	pgpa_init_trove_slice(&trove->rel);
+	pgpa_init_trove_slice(&trove->scan);
+
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		switch (item->tag)
+		{
+			case PGPA_TAG_JOIN_ORDER:
+				{
+					pgpa_advice_target *target;
+
+					/*
+					 * For most advice types, each element in the top-level
+					 * list is a separate target, but it's most convenient to
+					 * regard the entirety of a JOIN_ORDER specification as a
+					 * single target. Since it wasn't represented that way
+					 * during parsing, build a surrogate object now.
+					 */
+					target = palloc0_object(pgpa_advice_target);
+					target->ttype = PGPA_TARGET_ORDERED_LIST;
+					target->children = item->targets;
+
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_INDEX_ONLY_SCAN:
+			case PGPA_TAG_INDEX_SCAN:
+			case PGPA_TAG_SEQ_SCAN:
+			case PGPA_TAG_TID_SCAN:
+
+				/*
+				 * Scan advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					/*
+					 * For now, all of our scan types target single relations,
+					 * but in the future this might not be true, e.g. a custom
+					 * scan could replace a join.
+					 */
+					Assert(target->ttype == PGPA_TARGET_IDENTIFIER);
+					pgpa_trove_add_to_slice(&trove->scan,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_FOREIGN_JOIN:
+			case PGPA_TAG_HASH_JOIN:
+			case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			case PGPA_TAG_MERGE_JOIN_PLAIN:
+			case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			case PGPA_TAG_NESTED_LOOP_PLAIN:
+			case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			case PGPA_TAG_SEMIJOIN_UNIQUE:
+
+				/*
+				 * Join strategy advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_PARTITIONWISE:
+			case PGPA_TAG_GATHER:
+			case PGPA_TAG_GATHER_MERGE:
+			case PGPA_TAG_NO_GATHER:
+
+				/*
+				 * Advice about a RelOptInfo relevant to both scans and joins.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->rel,
+											item->tag, target);
+				}
+				break;
+		}
+	}
+
+	return trove;
+}
+
+/*
+ * Search a trove of advice for relevant entries.
+ *
+ * All parameters are input parameters except for *result, which is an output
+ * parameter used to return results to the caller.
+ */
+void
+pgpa_trove_lookup(pgpa_trove *trove, pgpa_trove_lookup_type type,
+				  int nrids, pgpa_identifier *rids, pgpa_trove_result *result)
+{
+	pgpa_trove_slice *tslice;
+	Bitmapset  *indexes;
+
+	Assert(nrids > 0);
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	indexes = pgpa_trove_slice_lookup(tslice, &rids[0]);
+	for (int i = 1; i < nrids; ++i)
+	{
+		Bitmapset  *other_indexes;
+
+		/*
+		 * If the caller is asking about two relations that aren't part of the
+		 * same subquery, they've messed up.
+		 */
+		Assert(strings_equal_or_both_null(rids[0].plan_name,
+										  rids[i].plan_name));
+
+		other_indexes = pgpa_trove_slice_lookup(tslice, &rids[i]);
+		indexes = bms_union(indexes, other_indexes);
+	}
+
+	result->entries = tslice->entries;
+	result->indexes = indexes;
+}
+
+/*
+ * Return all entries in a trove slice to the caller.
+ *
+ * The first two arguments are input arguments, and the remainder are output
+ * arguments.
+ */
+void
+pgpa_trove_lookup_all(pgpa_trove *trove, pgpa_trove_lookup_type type,
+					  pgpa_trove_entry **entries, int *nentries)
+{
+	pgpa_trove_slice *tslice;
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	*entries = tslice->entries;
+	*nentries = tslice->nused;
+}
+
+/*
+ * Convert a trove entry to an item of plan advice that would produce it.
+ */
+char *
+pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	appendStringInfo(&buf, "%s", pgpa_cstring_advice_tag(entry->tag));
+
+	/* JOIN_ORDER tags are transformed by pgpa_build_trove; undo that here */
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, '(');
+	else
+		Assert(entry->target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	pgpa_format_advice_target(&buf, entry->target);
+
+	if (entry->target->itarget != NULL)
+	{
+		appendStringInfoChar(&buf, ' ');
+		pgpa_format_index_target(&buf, entry->target->itarget);
+	}
+
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, ')');
+
+	return buf.data;
+}
+
+/*
+ * Set PGPA_TE_* flags on a set of trove entries.
+ */
+void
+pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
+{
+	int			i = -1;
+
+	while ((i = bms_next_member(indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+
+		entry->flags |= flags;
+	}
+}
+
+/*
+ * Add a new advice target to an existing pgpa_trove_slice object.
+ */
+static void
+pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+						pgpa_advice_tag_type tag,
+						pgpa_advice_target *target)
+{
+	pgpa_trove_entry *entry;
+
+	if (tslice->nused >= tslice->nallocated)
+	{
+		int			new_allocated;
+
+		new_allocated = tslice->nallocated * 2;
+		tslice->entries = repalloc_array(tslice->entries, pgpa_trove_entry,
+										 new_allocated);
+		tslice->nallocated = new_allocated;
+	}
+
+	entry = &tslice->entries[tslice->nused];
+	entry->tag = tag;
+	entry->target = target;
+	entry->flags = 0;
+
+	pgpa_trove_add_to_hash(tslice->hash, target, tslice->nused);
+
+	tslice->nused++;
+}
+
+/*
+ * Update the hash table for a newly-added advice target.
+ */
+static void
+pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash, pgpa_advice_target *target,
+					   int index)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	bool		found;
+
+	/* For non-identifiers, add entries for all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_trove_add_to_hash(hash, child_target, index);
+		}
+		return;
+	}
+
+	/* Sanity checks. */
+	Assert(target->rid.occurrence > 0);
+	Assert(target->rid.alias_name != NULL);
+
+	/* Add an entry for this relation identifier. */
+	key.alias_name = target->rid.alias_name;
+	key.partition_name = target->rid.partrel;
+	key.plan_name = target->rid.plan_name;
+	element = pgpa_trove_entry_insert(hash, key, &found);
+	element->indexes = bms_add_member(element->indexes, index);
+}
+
+/*
+ * Create and initialize a new pgpa_trove_slice object.
+ */
+static void
+pgpa_init_trove_slice(pgpa_trove_slice *tslice)
+{
+	/*
+	 * In an ideal world, we'll make tslice->nallocated big enough that the
+	 * array and hash table will be large enough to contain the number of
+	 * advice items in this trove slice, but a generous default value is not
+	 * good for performance, because pgpa_init_trove_slice() has to zero an
+	 * amount of memory proportional to tslice->nallocated. Hence, we keep the
+	 * starting value quite small, on the theory that advice strings will
+	 * often be relatively short.
+	 */
+	tslice->nallocated = 16;
+	tslice->nused = 0;
+	tslice->entries = palloc_array(pgpa_trove_entry, tslice->nallocated);
+	tslice->hash = pgpa_trove_entry_create(CurrentMemoryContext,
+										   tslice->nallocated, NULL);
+}
+
+/*
+ * Fast hash function for a key consisting of alias_name, partition_name,
+ * and plan_name.
+ */
+static uint32
+pgpa_trove_entry_hash_key(pgpa_trove_entry_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	/* alias_name may not be NULL */
+	sp_len = fasthash_accum_cstring(&hs, key.alias_name);
+
+	/* partition_name and plan_name, however, can be NULL */
+	if (key.partition_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.partition_name);
+	if (key.plan_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.plan_name);
+
+	/*
+	 * hashfn_unstable.h recommends using string length as tweak. It's not
+	 * clear to me what to do if there are multiple strings, so for now I'm
+	 * just using the total of all of the lengths.
+	 */
+	return fasthash_final32(&hs, sp_len);
+}
+
+/*
+ * Look for matching entries.
+ */
+static Bitmapset *
+pgpa_trove_slice_lookup(pgpa_trove_slice *tslice, pgpa_identifier *rid)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	Bitmapset  *result = NULL;
+
+	Assert(rid->occurrence >= 1);
+
+	key.alias_name = rid->alias_name;
+	key.partition_name = rid->partrel;
+	key.plan_name = rid->plan_name;
+
+	element = pgpa_trove_entry_lookup(tslice->hash, key);
+
+	if (element != NULL)
+	{
+		int			i = -1;
+
+		while ((i = bms_next_member(element->indexes, i)) >= 0)
+		{
+			pgpa_trove_entry *entry = &tslice->entries[i];
+
+			/*
+			 * We know that this target or one of its descendents matches the
+			 * identifier on the three key fields above, but we don't know
+			 * which descendent or whether the occurence and schema also
+			 * match.
+			 */
+			if (pgpa_identifier_matches_target(rid, entry->target))
+				result = bms_add_member(result, i);
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
new file mode 100644
index 00000000000..479c3f75778
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -0,0 +1,113 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.h
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_TROVE_H
+#define PGPA_TROVE_H
+
+#include "pgpa_ast.h"
+
+#include "nodes/bitmapset.h"
+
+typedef struct pgpa_trove pgpa_trove;
+
+/*
+ * Flags that can be set on a pgpa_trove_entry to indicate what happened when
+ * trying to plan using advice.
+ *
+ * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_TE_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_TE_MATCH_PARTIAL		0x0001
+#define PGPA_TE_MATCH_FULL			0x0002
+#define PGPA_TE_INAPPLICABLE		0x0004
+#define PGPA_TE_CONFLICTING			0x0008
+#define PGPA_TE_FAILED				0x0010
+
+/*
+ * Each entry in a trove of advice represents the application of a tag to
+ * a single target.
+ */
+typedef struct pgpa_trove_entry
+{
+	pgpa_advice_tag_type tag;
+	pgpa_advice_target *target;
+	int			flags;
+} pgpa_trove_entry;
+
+/*
+ * What kind of information does the caller want to find in a trove?
+ *
+ * PGPA_TROVE_LOOKUP_SCAN means we're looking for scan advice.
+ *
+ * PGPA_TROVE_LOOKUP_JOIN means we're looking for join-related advice.
+ * This includes join order advice, join method advice, and semijoin-uniqueness
+ * advice.
+ *
+ * PGPA_TROVE_LOOKUP_REL means we're looking for general advice about this
+ * a RelOptInfo that may correspond to either a scan or a join. This includes
+ * gather-related advice and partitionwise advice. Note that partitionwise
+ * advice might seem like join advice, but that's not a helpful way of viewing
+ * the matter because (1) partitionwise advice is also relevant at the scan
+ * level and (2) other types of join advice affect only what to do from
+ * join_path_setup_hook, but partitionwise advice affects what to do in
+ * joinrel_setup_hook.
+ */
+typedef enum pgpa_trove_lookup_type
+{
+	PGPA_TROVE_LOOKUP_JOIN,
+	PGPA_TROVE_LOOKUP_REL,
+	PGPA_TROVE_LOOKUP_SCAN
+} pgpa_trove_lookup_type;
+
+/*
+ * This struct is used to store the result of a trove lookup. For each member
+ * of "indexes", the entry at the corresponding offset within "entries" is one
+ * of the results.
+ */
+typedef struct pgpa_trove_result
+{
+	pgpa_trove_entry *entries;
+	Bitmapset  *indexes;
+} pgpa_trove_result;
+
+extern pgpa_trove *pgpa_build_trove(List *advice_items);
+extern void pgpa_trove_lookup(pgpa_trove *trove,
+							  pgpa_trove_lookup_type type,
+							  int nrids,
+							  pgpa_identifier *rids,
+							  pgpa_trove_result *result);
+extern void pgpa_trove_lookup_all(pgpa_trove *trove,
+								  pgpa_trove_lookup_type type,
+								  pgpa_trove_entry **entries,
+								  int *nentries);
+extern char *pgpa_cstring_trove_entry(pgpa_trove_entry *entry);
+extern void pgpa_trove_set_flags(pgpa_trove_entry *entries,
+								 Bitmapset *indexes, int flags);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
new file mode 100644
index 00000000000..7e4e388603a
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -0,0 +1,862 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.c
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/plannodes.h"
+
+static void pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+								  bool within_join_problem,
+								  pgpa_join_unroller *join_unroller,
+								  List *active_query_features,
+								  bool beneath_any_gather);
+static Bitmapset *pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+											 pgpa_unrolled_join *ujoin);
+
+static pgpa_query_feature *pgpa_add_feature(pgpa_plan_walker_context *walker,
+											pgpa_qf_type type,
+											Plan *plan);
+
+static void pgpa_qf_add_rti(List *active_query_features, Index rti);
+static void pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids);
+static void pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan);
+
+static bool pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+										   Index rtable_length,
+										   pgpa_identifier *rt_identifiers,
+										   pgpa_advice_target *target,
+										   bool toplevel);
+static bool pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+												  Index rtable_length,
+												  pgpa_identifier *rt_identifiers,
+												  pgpa_advice_target *target);
+static bool pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+									  pgpa_scan_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+										 pgpa_qf_type type,
+										 Bitmapset *relids);
+static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+									  pgpa_join_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+										   Bitmapset *relids);
+static Index pgpa_walker_get_rti(Index rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid);
+
+/*
+ * Top-level entrypoint for the plan tree walk.
+ *
+ * Populates walker based on a traversal of the Plan trees in pstmt.
+ */
+void
+pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt)
+{
+	ListCell   *lc;
+
+	/* Initialization. */
+	memset(walker, 0, sizeof(pgpa_plan_walker_context));
+	walker->pstmt = pstmt;
+
+	/* Walk the main plan tree. */
+	pgpa_walk_recursively(walker, pstmt->planTree, 0, NULL, NIL, false);
+
+	/* Main plan tree walk won't reach subplans, so walk those. */
+	foreach(lc, pstmt->subplans)
+	{
+		Plan	   *plan = lfirst(lc);
+
+		if (plan != NULL)
+			pgpa_walk_recursively(walker, plan, 0, NULL, NIL, false);
+	}
+}
+
+/*
+ * Main workhorse for the plan tree walk.
+ *
+ * If within_join_problem is true, we encountered a join at some higher level
+ * of the tree walk and haven't yet descended out of the portion of the plan
+ * tree that is part of that same join problem. We're no longer in the same
+ * join problem if (1) we cross into a different subquery or (2) we descend
+ * through an Append or MergeAppend node, below which any further joins would
+ * be partitionwise joins planned separately from the outer join problem.
+ *
+ * If join_unroller != NULL, the join unroller code expects us to find a join
+ * that should be unrolled into that object. This implies that we're within a
+ * join problem, but the reverse is not true: when we've traversed all the
+ * joins but are still looking for the scan that is the leaf of the join tree,
+ * join_unroller will be NULL but within_join_problem will be true.
+ *
+ * Each element of active_query_features corresponds to some item of advice
+ * that needs to enumerate all the relations it affects. We add RTIs we find
+ * during tree traversal to each of these query features.
+ *
+ * If beneath_any_gather == true, some higher level of the tree traversal found
+ * a Gather or Gather Merge node.
+ */
+static void
+pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+					  bool within_join_problem,
+					  pgpa_join_unroller *join_unroller,
+					  List *active_query_features,
+					  bool beneath_any_gather)
+{
+	pgpa_join_unroller *outer_join_unroller = NULL;
+	pgpa_join_unroller *inner_join_unroller = NULL;
+	bool		join_unroller_toplevel = false;
+	List	   *pushdown_query_features = NIL;
+	ListCell   *lc;
+	List	   *extraplans = NIL;
+	List	   *elided_nodes = NIL;
+
+	Assert(within_join_problem || join_unroller == NULL);
+
+	/*
+	 * If this is a Gather or Gather Merge node, directly add it to the list
+	 * of currently-active query features.
+	 *
+	 * Otherwise, check the future_query_features list to see whether this was
+	 * previously identified as a plan node that needs to be treated as a
+	 * query feature.
+	 *
+	 * Note that the caller also has a copy to active_query_features, so we
+	 * can't destructively modify it without making a copy.
+	 */
+	if (IsA(plan, Gather))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER, plan));
+		beneath_any_gather = true;
+	}
+	else if (IsA(plan, GatherMerge))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER_MERGE, plan));
+		beneath_any_gather = true;
+	}
+	else
+	{
+		foreach_ptr(pgpa_query_feature, qf, walker->future_query_features)
+		{
+			if (qf->plan == plan)
+			{
+				active_query_features = list_copy(active_query_features);
+				active_query_features = lappend(active_query_features, qf);
+				walker->future_query_features =
+					list_delete_ptr(walker->future_query_features, plan);
+				break;
+			}
+		}
+	}
+
+	/*
+	 * Find all elided nodes for this Plan node.
+	 */
+	foreach_node(ElidedNode, n, walker->pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_nodes = lappend(elided_nodes, n);
+	}
+
+	/* If we found any elided_nodes, handle them. */
+	if (elided_nodes != NIL)
+	{
+		int			num_elided_nodes = list_length(elided_nodes);
+		ElidedNode *last_elided_node;
+
+		/*
+		 * RTIs for the final -- and thus logically uppermost -- elided node
+		 * should be collected for query features passed down by the caller.
+		 * However, elided nodes act as barriers to query features, which
+		 * means that (1) the remaining elided nodes, if any, should be
+		 * ignored for purposes of query features and (2) the list of active
+		 * query features should be reset to empty so that we do not add RTIs
+		 * from the plan node that is logically beneath the elided node to the
+		 * query features passed down from the caller.
+		 */
+		last_elided_node = list_nth(elided_nodes, num_elided_nodes - 1);
+		pgpa_qf_add_rtis(active_query_features, last_elided_node->relids);
+		active_query_features = NIL;
+
+		/*
+		 * If we're within a join problem, the join_unroller is responsible
+		 * for building the scan for the final elided node, so throw it out.
+		 */
+		if (within_join_problem)
+			elided_nodes = list_truncate(elided_nodes, num_elided_nodes - 1);
+
+		/* Build scans for all (or the remaining) elided nodes. */
+		foreach_node(ElidedNode, elided_node, elided_nodes)
+		{
+			(void) pgpa_build_scan(walker, plan, elided_node,
+								   beneath_any_gather, within_join_problem);
+		}
+
+		/*
+		 * If there were any elided nodes, then everything beneath those nodes
+		 * is not part of the same join problem.
+		 *
+		 * In more detail, if an Append or MergeAppend was elided, then a
+		 * partitionwise join was chosen and only a single child survived; if
+		 * a SubqueryScan was elided, the subquery was planned without
+		 * flattening it into the parent.
+		 */
+		within_join_problem = false;
+		join_unroller = NULL;
+	}
+
+	/*
+	 * If we're within a join problem, the join unroller is responsible for
+	 * building any required scan for this node. If not, we do it here.
+	 */
+	if (!within_join_problem)
+		(void) pgpa_build_scan(walker, plan, NULL, beneath_any_gather, false);
+
+	/*
+	 * If this join needs to unrolled but there's no join unroller already
+	 * available, create one.
+	 */
+	if (join_unroller == NULL && pgpa_is_join(plan))
+	{
+		join_unroller = pgpa_create_join_unroller();
+		join_unroller_toplevel = true;
+		within_join_problem = true;
+	}
+
+	/*
+	 * If this join is to be unrolled, pgpa_unroll_join() will return the join
+	 * unroller object that should be passed down when we recurse into the
+	 * outer and inner sides of the plan.
+	 */
+	if (join_unroller != NULL)
+		pgpa_unroll_join(walker, plan, beneath_any_gather, join_unroller,
+						 &outer_join_unroller, &inner_join_unroller);
+
+	/* Add RTIs from the plan node to all active query features. */
+	pgpa_qf_add_plan_rtis(active_query_features, plan);
+
+	/*
+	 * Recurse into the outer and inner subtrees.
+	 *
+	 * As an exception, if this is a ForeignScan, don't recurse. postgres_fdw
+	 * sometimes stores an EPQ recheck plan in plan->leftree, but that's going
+	 * to mention the same set of relations as the ForeignScan itself, and we
+	 * have no way to emit advice targeting the EPQ case vs. the non-EPQ case.
+	 * Moreover, it's not entirely clear what other FDWs might do with the
+	 * left and right subtrees. Maybe some better handling is needed here, but
+	 * for now, we just punt.
+	 */
+	if (!IsA(plan, ForeignScan))
+	{
+		if (plan->lefttree != NULL)
+			pgpa_walk_recursively(walker, plan->lefttree, within_join_problem,
+								  outer_join_unroller, active_query_features,
+								  beneath_any_gather);
+		if (plan->righttree != NULL)
+			pgpa_walk_recursively(walker, plan->righttree, within_join_problem,
+								  inner_join_unroller, active_query_features,
+								  beneath_any_gather);
+	}
+
+	/*
+	 * If we created a join unroller up above, then it's also our join to use
+	 * it to build the final pgpa_unrolled_join, and to destroy the object.
+	 */
+	if (join_unroller_toplevel)
+	{
+		pgpa_unrolled_join *ujoin;
+
+		ujoin = pgpa_build_unrolled_join(walker, join_unroller);
+		walker->toplevel_unrolled_joins =
+			lappend(walker->toplevel_unrolled_joins, ujoin);
+		pgpa_destroy_join_unroller(join_unroller);
+		(void) pgpa_process_unrolled_join(walker, ujoin);
+	}
+
+	/*
+	 * Some plan types can have additional children. Nodes like Append that
+	 * can have any number of children store them in a List; a SubqueryScan
+	 * just has a field for a single additional Plan.
+	 */
+	switch (nodeTag(plan))
+	{
+		case T_Append:
+			{
+				Append	   *aplan = (Append *) plan;
+
+				extraplans = aplan->appendplans;
+				if (bms_is_empty(aplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_MergeAppend:
+			{
+				MergeAppend *maplan = (MergeAppend *) plan;
+
+				extraplans = maplan->mergeplans;
+				if (bms_is_empty(maplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_BitmapAnd:
+			extraplans = ((BitmapAnd *) plan)->bitmapplans;
+			break;
+		case T_BitmapOr:
+			extraplans = ((BitmapOr *) plan)->bitmapplans;
+			break;
+		case T_SubqueryScan:
+
+			/*
+			 * We don't pass down active_query_features across here, because
+			 * those are specific to a subquery level.
+			 */
+			pgpa_walk_recursively(walker, ((SubqueryScan *) plan)->subplan,
+								  0, NULL, NIL, beneath_any_gather);
+			break;
+		case T_CustomScan:
+			extraplans = ((CustomScan *) plan)->custom_plans;
+			break;
+		default:
+			break;
+	}
+
+	/* If we found a list of extra children, iterate over it. */
+	foreach(lc, extraplans)
+	{
+		Plan	   *subplan = lfirst(lc);
+
+		pgpa_walk_recursively(walker, subplan, 0, NULL, pushdown_query_features,
+							  beneath_any_gather);
+	}
+}
+
+/*
+ * Perform final processing of a newly-constructed pgpa_unrolled_join. This
+ * only needs to be called for toplevel pgpa_unrolled_join objects, since it
+ * recurses to sub-joins as needed.
+ *
+ * Our goal is to add the set of inner relids to the relevant join_strategies
+ * list, and to do the same for any sub-joins. To that end, the return value
+ * is the set of relids found beneath the inner side of the join, but it is
+ * expected that the toplevel caller will ignore this.
+ */
+static Bitmapset *
+pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+						   pgpa_unrolled_join *ujoin)
+{
+	Bitmapset  *all_relids = NULL;
+
+	for (int k = 0; k < ujoin->ninner; ++k)
+	{
+		pgpa_join_member *member = &ujoin->inner[k];
+		Bitmapset  *relids;
+
+		if (member->unrolled_join != NULL)
+			relids = pgpa_process_unrolled_join(walker,
+												member->unrolled_join);
+		else
+		{
+			Assert(member->scan != NULL);
+			relids = member->scan->relids;
+		}
+		walker->join_strategies[ujoin->strategy[k]] =
+			lappend(walker->join_strategies[ujoin->strategy[k]], relids);
+		all_relids = bms_add_members(all_relids, relids);
+	}
+
+	return all_relids;
+}
+
+/*
+ * Arrange for the given plan node to be treated as a query feature when the
+ * tree walk reaches it.
+ *
+ * Make sure to only use this for nodes that the tree walk can't have reached
+ * yet!
+ */
+void
+pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+						pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = pgpa_add_feature(walker, type, plan);
+
+	walker->future_query_features =
+		lappend(walker->future_query_features, qf);
+}
+
+/*
+ * Return the last of any elided nodes associated with this plan node ID.
+ *
+ * The last elided node is the one that would have been uppermost in the plan
+ * tree had it not been removed during setrefs processig.
+ */
+ElidedNode *
+pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan)
+{
+	ElidedNode *elided_node = NULL;
+
+	foreach_node(ElidedNode, n, pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_node = n;
+	}
+
+	return elided_node;
+}
+
+/*
+ * Certain plan nodes can refer to a set of RTIs. Extract and return the set.
+ */
+Bitmapset *
+pgpa_relids(Plan *plan)
+{
+	if (IsA(plan, Result))
+		return ((Result *) plan)->relids;
+	else if (IsA(plan, ForeignScan))
+		return ((ForeignScan *) plan)->fs_relids;
+	else if (IsA(plan, Append))
+		return ((Append *) plan)->apprelids;
+	else if (IsA(plan, MergeAppend))
+		return ((MergeAppend *) plan)->apprelids;
+
+	return NULL;
+}
+
+/*
+ * Extract the scanned RTI from a plan node.
+ *
+ * Returns 0 if there isn't one.
+ */
+Index
+pgpa_scanrelid(Plan *plan)
+{
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+		case T_SubqueryScan:
+		case T_FunctionScan:
+		case T_TableFuncScan:
+		case T_ValuesScan:
+		case T_CteScan:
+		case T_NamedTuplestoreScan:
+		case T_WorkTableScan:
+		case T_ForeignScan:
+		case T_CustomScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+			return ((Scan *) plan)->scanrelid;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Create a pgpa_query_feature and add it to the list of all query features
+ * for this plan.
+ */
+static pgpa_query_feature *
+pgpa_add_feature(pgpa_plan_walker_context *walker,
+				 pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = palloc0_object(pgpa_query_feature);
+
+	qf->type = type;
+	qf->plan = plan;
+
+	walker->query_features[qf->type] =
+		lappend(walker->query_features[qf->type], qf);
+
+	return qf;
+}
+
+/*
+ * Add a single RTI to each active query feature.
+ */
+static void
+pgpa_qf_add_rti(List *active_query_features, Index rti)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_member(qf->relids, rti);
+	}
+}
+
+/*
+ * Add a set of RTIs to each active query feature.
+ */
+static void
+pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_members(qf->relids, relids);
+	}
+}
+
+/*
+ * Add RTIs directly contained in a plan node to each active query feature.
+ */
+static void
+pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan)
+{
+	Bitmapset  *relids;
+	Index		rti;
+
+	if ((relids = pgpa_relids(plan)) != NULL)
+		pgpa_qf_add_rtis(active_query_features, relids);
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+		pgpa_qf_add_rti(active_query_features, rti);
+}
+
+/*
+ * If we generated plan advice using the provided walker object and array
+ * of identifiers, would we generate the specified tag/target combination?
+ *
+ * If yes, the plan conforms to the advice; if no, it does not. Note that
+ * we have know way of knowing whether the planner was forced to emit a plan
+ * that conformed to the advice or just happened to do so.
+ */
+bool
+pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+						 pgpa_identifier *rt_identifiers,
+						 pgpa_advice_tag_type tag,
+						 pgpa_advice_target *target)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	Bitmapset  *relids = NULL;
+
+	if (tag == PGPA_TAG_JOIN_ORDER)
+	{
+		foreach_ptr(pgpa_unrolled_join, ujoin, walker->toplevel_unrolled_joins)
+		{
+			if (pgpa_walker_join_order_matches(ujoin, rtable_length,
+											   rt_identifiers, target, true))
+				return true;
+		}
+
+		return false;
+	}
+
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		Index		rti;
+
+		rti = pgpa_walker_get_rti(rtable_length, rt_identifiers, &target->rid);
+		relids = bms_make_singleton(rti);
+	}
+	else
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			Index		rti;
+
+			Assert(child_target->ttype == PGPA_TARGET_IDENTIFIER);
+			rti = pgpa_compute_rti_from_identifier(rtable_length,
+												   rt_identifiers,
+												   &child_target->rid);
+			if (rti == 0)
+				elog(ERROR, "cannot determine RTI for advice target");
+			relids = bms_add_member(relids, rti);
+		}
+	}
+
+	switch (tag)
+	{
+		case PGPA_TAG_JOIN_ORDER:
+			/* should have been handled above */
+			pg_unreachable();
+			break;
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_BITMAP_HEAP,
+											 relids);
+		case PGPA_TAG_FOREIGN_JOIN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_FOREIGN,
+											 relids);
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX_ONLY,
+											 relids);
+		case PGPA_TAG_INDEX_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX,
+											 relids);
+		case PGPA_TAG_PARTITIONWISE:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_PARTITIONWISE,
+											 relids);
+		case PGPA_TAG_SEQ_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_SEQ,
+											 relids);
+		case PGPA_TAG_TID_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_TID,
+											 relids);
+		case PGPA_TAG_GATHER:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER,
+												relids);
+		case PGPA_TAG_GATHER_MERGE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER_MERGE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_NON_UNIQUE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_UNIQUE,
+												relids);
+		case PGPA_TAG_HASH_JOIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_HASH_JOIN,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_PLAIN,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MEMOIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_PLAIN,
+											 relids);
+		case PGPA_TAG_NO_GATHER:
+			return pgpa_walker_contains_no_gather(walker, relids);
+	}
+
+	/* should not get here */
+	return false;
+}
+
+/*
+ * Does an unrolled join match the join order specified by an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+							   Index rtable_length,
+							   pgpa_identifier *rt_identifiers,
+							   pgpa_advice_target *target,
+							   bool toplevel)
+{
+	int		nchildren = list_length(target->children);
+
+	Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	/* At toplevel, we allow a prefix match. */
+	if (toplevel)
+	{
+		if (nchildren > ujoin->ninner + 1)
+			return false;
+	}
+	else
+	{
+		if (nchildren != ujoin->ninner + 1)
+			return false;
+	}
+
+	/* Outermost rel must match. */
+	if (!pgpa_walker_join_order_matches_member(&ujoin->outer,
+											   rtable_length,
+											   rt_identifiers,
+											   linitial(target->children)))
+		return false;
+
+	/* Each inner rel must match. */
+	for (int n = 0; n < nchildren - 1; ++n)
+	{
+		pgpa_advice_target *child_target = list_nth(target->children, n + 1);
+
+		if (!pgpa_walker_join_order_matches_member(&ujoin->inner[n],
+												   rtable_length,
+												   rt_identifiers,
+												   child_target))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Does one member of an unrolled join match an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+									  Index rtable_length,
+									  pgpa_identifier *rt_identifiers,
+									  pgpa_advice_target *target)
+{
+	Bitmapset  *relids = NULL;
+
+	if (member->unrolled_join != NULL)
+	{
+		if (target->ttype != PGPA_TARGET_ORDERED_LIST)
+			return false;
+		return pgpa_walker_join_order_matches(member->unrolled_join,
+											  rtable_length,
+											  rt_identifiers,
+											  target,
+											  false);
+	}
+
+	Assert(member->scan != NULL);
+	switch (target->ttype)
+	{
+		case PGPA_TARGET_ORDERED_LIST:
+			/* Could only match an unrolled join */
+			return false;
+
+		case PGPA_TARGET_UNORDERED_LIST:
+			{
+				foreach_ptr(pgpa_advice_target, child_target, target->children)
+				{
+					Index		rti;
+
+					rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+											  &child_target->rid);
+					relids = bms_add_member(relids, rti);
+				}
+				break;
+			}
+
+		case PGPA_TARGET_IDENTIFIER:
+			{
+				Index		rti;
+
+				rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+										  &target->rid);
+				relids = bms_make_singleton(rti);
+				break;
+			}
+	}
+
+	return bms_equal(member->scan->relids, relids);
+}
+
+/*
+ * Does this walker say that the given scan strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+						  pgpa_scan_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *scans = walker->scans[strategy];
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		/*
+		 * XXX. If this is index-related advice, we should also validate that
+		 * the advice target's index target matches the Plan tree.
+		 */
+		if (bms_equal(scan->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does this walker say that the given query feature applies to the given
+ * relid set?
+ */
+static bool
+pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+							 pgpa_qf_type type,
+							 Bitmapset *relids)
+{
+	List	   *query_features = walker->query_features[type];
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (bms_equal(qf->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given join strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+						  pgpa_join_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *join_strategies = walker->join_strategies[strategy];
+
+	foreach_ptr(Bitmapset, jsrelids, join_strategies)
+	{
+		if (bms_equal(jsrelids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given relids should be marked as NO_GATHER?
+ */
+static bool
+pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+							   Bitmapset *relids)
+{
+	return bms_is_subset(relids, walker->no_gather_scans);
+}
+
+/*
+ * Convenience function to convert a relation identifier to an RTI.
+ *
+ * We throw an error here because we expect this to be used on system-generated
+ * advice. Hence, failure here indicates an advice generation bug.
+ */
+static Index
+pgpa_walker_get_rti(Index rtable_length,
+					pgpa_identifier *rt_identifiers,
+					pgpa_identifier *rid)
+{
+	Index		rti;
+
+	rti = pgpa_compute_rti_from_identifier(rtable_length,
+										   rt_identifiers,
+										   rid);
+	if (rti == 0)
+		elog(ERROR, "cannot determine RTI for advice target");
+	return rti;
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
new file mode 100644
index 00000000000..d6584c014b9
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -0,0 +1,121 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.h
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_WALKER_H
+#define PGPA_WALKER_H
+
+#include "pgpa_ast.h"
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+
+/*
+ * We use the term "query feature" to refer to plan nodes that are interesting
+ * in the following way: to generate advice, we'll need to know the set of
+ * same-subquery, non-join RTIs occuring at or below that plan node, without
+ * admixture of parent and child RTIs.
+ *
+ * For example, Gather nodes, desiginated by PGPAQF_GATHER, and Gather Merge
+ * nodes, designated by PGPAQF_GATHER_MERGE, are query features, because we'll
+ * want to admit some kind of advice that describes the portion of the plan
+ * tree that appears beneath those nodes.
+ *
+ * Each semijoin can be implemented either by directly performing a semijoin,
+ * or by making one side unique and then performing a normal join. Either way,
+ * we use a query feature to notice what decision was made, so that we can
+ * describe it by enumerating the RTIs on that side of the join.
+ *
+ * To elaborate on the "no admixture of parent and child RTIs" rule, in all of
+ * these cases, if the entirety of an inheritance hierarchy appears beneath
+ * the query feature, we only want to name the parent table. But it's also
+ * possible to have cases where we must name child tables. This is particularly
+ * likely to happen when partitionwise join is in use, but could happen for
+ * Gather or Gather Merge even without that, if one of those appears below
+ * an Append or MergeAppend node for a single table.
+ */
+typedef enum pgpa_qf_type
+{
+	PGPAQF_GATHER,
+	PGPAQF_GATHER_MERGE,
+	PGPAQF_SEMIJOIN_NON_UNIQUE,
+	PGPAQF_SEMIJOIN_UNIQUE
+	/* update NUM_PGPA_QF_TYPES if you add anything here */
+} pgpa_qf_type;
+
+#define NUM_PGPA_QF_TYPES ((int) PGPAQF_SEMIJOIN_UNIQUE + 1)
+
+/*
+ * For each query feature, we keep track of the feature type and the set of
+ * relids that we found underneath the relevant plan node. See the comments
+ * on pgpa_qf_type, above, for additional details.
+ */
+typedef struct pgpa_query_feature
+{
+	pgpa_qf_type type;
+	Plan	   *plan;
+	Bitmapset  *relids;
+} pgpa_query_feature;
+
+/*
+ * Context object for plan tree walk.
+ *
+ * pstmt is the PlannedStmt we're studying.
+ *
+ * scans is an array of lists of pgpa_scan objects. The array is indexed by
+ * the scan's pgpa_scan_strategy.
+ *
+ * no_gather_scans is the set of scan RTIs that do not appear beneath any
+ * Gather or Gather Merge node.
+ *
+ * toplevel_unrolled_joins is a list of all pgpa_unrolled_join objects that
+ * are not a child of some other pgpa_unrolled_join.
+ *
+ * join_strategy is an array of lists of Bitmapset objects. Each Bitmapset
+ * is the set of relids that appears on the inner side of some join (excluding
+ * RTIs from partition children and subqueries). The array is indexed by
+ * pgpa_join_strategy.
+ *
+ * query_features is an array lists of pgpa_query_feature objects, indexed
+ * by pgpa_qf_type.
+ *
+ * future_query_features is only used during the plan tree walk and should
+ * be empty when the tree walk concludes. It is a list of pgpa_query_feature
+ * objects for Plan nodes that the plan tree walk has not yet encountered;
+ * when encountered, they will be moved to the list of active query features
+ * that is propagated via the call stack.
+ */
+typedef struct pgpa_plan_walker_context
+{
+	PlannedStmt *pstmt;
+	List	   *scans[NUM_PGPA_SCAN_STRATEGY];
+	Bitmapset  *no_gather_scans;
+	List	   *toplevel_unrolled_joins;
+	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
+	List	   *query_features[NUM_PGPA_QF_TYPES];
+	List	   *future_query_features;
+} pgpa_plan_walker_context;
+
+extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
+							 PlannedStmt *pstmt);
+
+extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+									pgpa_qf_type type,
+									Plan *plan);
+
+extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
+extern Bitmapset *pgpa_relids(Plan *plan);
+extern Index pgpa_scanrelid(Plan *plan);
+
+extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+									 pgpa_identifier *rt_identifiers,
+									 pgpa_advice_tag_type tag,
+									 pgpa_advice_target *target);
+
+#endif
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
new file mode 100644
index 00000000000..6b15e18e98e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -0,0 +1,75 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_order.sql b/contrib/pg_plan_advice/sql/join_order.sql
new file mode 100644
index 00000000000..5aa2fc62d34
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_order.sql
@@ -0,0 +1,96 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
+
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/sql/join_strategy.sql b/contrib/pg_plan_advice/sql/join_strategy.sql
new file mode 100644
index 00000000000..8eb823f1c0e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_strategy.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/partitionwise.sql b/contrib/pg_plan_advice/sql/partitionwise.sql
new file mode 100644
index 00000000000..e42c0611760
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/partitionwise.sql
@@ -0,0 +1,78 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
new file mode 100644
index 00000000000..25416a75f46
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -0,0 +1,195 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+COMMIT;
+
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+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 > 0;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+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;
+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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+COMMIT;
+
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+COMMIT;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index bfe9ff0d92c..79de82d39fc 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3921,6 +3921,43 @@ pg_wc_probefunc
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgpa_collected_advice
+pgpa_advice_item
+pgpa_advice_tag_type
+pgpa_advice_target
+pgpa_identifier
+pgpa_index_target
+pgpa_index_type
+pgpa_itm_type
+pgpa_join_class
+pgpa_join_member
+pgpa_join_state
+pgpa_join_strategy
+pgpa_join_unroller
+pgpa_local_advice
+pgpa_local_advice_chunk
+pgpa_output_context
+pgpa_plan_walker_context
+pgpa_planner_state
+pgpa_qf_type
+pgpa_query_feature
+pgpa_ri_checker
+pgpa_ri_checker_key
+pgpa_scan
+pgpa_scan_strategy
+pgpa_shared_advice
+pgpa_shared_advice_chunk
+pgpa_shared_state
+pgpa_target_type
+pgpa_trove
+pgpa_trove_entry
+pgpa_trove_entry_element
+pgpa_trove_entry_hash
+pgpa_trove_entry_key
+pgpa_trove_lookup_type
+pgpa_trove_result
+pgpa_trove_slice
+pgpa_unrolled_join
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-06 16:45  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-11-06 16:45 UTC (permalink / raw)
  To: Jakub Wartak <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

Here's v3. I've attempted to fix some more things that cfbot didn't
like, one of which was an actual bug in 0005, and I also fixed a
stupid few bugs in pgpa_collector.c and added a few more tests.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v3-0005-Allow-for-plugin-control-over-path-generation-str.patch (55.4K, 2-v3-0005-Allow-for-plugin-control-over-path-generation-str.patch)
  download | inline diff:
From 05efbb59f18c403be4c7df2a5c115254d0dd81ae Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 24 Oct 2025 15:11:47 -0400
Subject: [PATCH v3 5/6] Allow for plugin control over path generation
 strategies.

Each RelOptInfo now has a pgs_mask member which is a mask of acceptable
strategies. For most rels, this is populated from PlannerGlobal's
default_pgs_mask, which is computed from the values of the enable_*
GUCs at the start of planning.

For baserels, get_relation_info_hook can be used to adjust pgs_mask for
each new RelOptInfo, at least for rels of type RTE_RELATION. Adjusting
pgs_mask is less useful for other types of rels, but if it proves to
be necessary, we can revisit the way this hook works or add a new one.

For joinrels, two new hooks are added. joinrel_setup_hook is called each
time a joinrel is created, and one thing that can be done from that hook
is to manipulate pgs_mask for the new joinrel. join_path_setup_hook is
called each time we're about to add paths to a joinrel by considering
some particular combination of an outer rel, an inner rel, and a join
type. It can modify the pgs_mask propagated into JoinPathExtraData to
restrict strategy choice for that paricular combination of rels.

To make joinrel_setup_hook work as intended, the existing calls to
build_joinrel_partition_info are moved later in the calling functions;
this is because that function checks whether the rel's pgs_mask includes
PGS_CONSIDER_PARTITIONWISE, so we want it to only be called after
plugins have had a chance to alter pgs_mask.

Upper rels currently inherit pgs_mask from the input relation. It's
unclear that this is the most useful behavior, but at the moment there
are no hooks to allow the mask to be set in any other way.
---
 src/backend/optimizer/path/allpaths.c   |   2 +-
 src/backend/optimizer/path/costsize.c   | 222 ++++++++++++++++++------
 src/backend/optimizer/path/indxpath.c   |   4 +-
 src/backend/optimizer/path/joinpath.c   |  88 +++++++---
 src/backend/optimizer/path/tidpath.c    |   7 +-
 src/backend/optimizer/plan/createplan.c |   1 +
 src/backend/optimizer/plan/planner.c    |  54 ++++++
 src/backend/optimizer/util/pathnode.c   |  19 +-
 src/backend/optimizer/util/plancat.c    |   3 +
 src/backend/optimizer/util/relnode.c    |  43 ++++-
 src/include/nodes/pathnodes.h           |  82 ++++++++-
 src/include/optimizer/cost.h            |   4 +-
 src/include/optimizer/pathnode.h        |  11 +-
 src/include/optimizer/paths.h           |   9 +-
 14 files changed, 452 insertions(+), 97 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 928b8d84ad8..8e9dde3d195 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -954,7 +954,7 @@ set_tablesample_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *
 		 bms_membership(root->all_query_rels) != BMS_SINGLETON) &&
 		!(GetTsmRoutine(rte->tablesample->tsmhandler)->repeatable_across_scans))
 	{
-		path = (Path *) create_material_path(rel, path);
+		path = (Path *) create_material_path(rel, path, true);
 	}
 
 	add_path(rel, path);
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 8335cf5b5c5..6e47c9f5893 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -275,6 +275,7 @@ cost_seqscan(Path *path, PlannerInfo *root,
 	double		spc_seq_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = PGS_SEQSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -327,8 +328,11 @@ cost_seqscan(Path *path, PlannerInfo *root,
 		 */
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	path->disabled_nodes = enable_seqscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
 }
@@ -354,6 +358,7 @@ cost_samplescan(Path *path, PlannerInfo *root,
 				spc_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations with tablesample clauses */
 	Assert(baserel->relid > 0);
@@ -401,7 +406,11 @@ cost_samplescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -440,7 +449,8 @@ cost_gather(GatherPath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows;
 
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost;
 	path->path.total_cost = (startup_cost + run_cost);
 }
@@ -506,8 +516,8 @@ cost_gather_merge(GatherMergePath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows * 1.05;
 
-	path->path.disabled_nodes = input_disabled_nodes
-		+ (enable_gathermerge ? 0 : 1);
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER_MERGE) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost + input_startup_cost;
 	path->path.total_cost = (startup_cost + run_cost + input_total_cost);
 }
@@ -557,6 +567,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	double		pages_fetched;
 	double		rand_heap_pages;
 	double		index_pages;
+	uint64		enable_mask;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo) &&
@@ -588,8 +599,11 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 											  path->indexclauses);
 	}
 
-	/* we don't need to check enable_indexonlyscan; indxpath.c does that */
-	path->path.disabled_nodes = enable_indexscan ? 0 : 1;
+	/* is this scan type disabled? */
+	enable_mask = (indexonly ? PGS_INDEXONLYSCAN : PGS_INDEXSCAN)
+		| (path->path.parallel_workers == 0 ? PGS_CONSIDER_NONPARTIAL : 0);
+	path->path.disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 
 	/*
 	 * Call index-access-method-specific code to estimate the processing cost
@@ -1010,6 +1024,7 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	double		spc_seq_page_cost,
 				spc_random_page_cost;
 	double		T;
+	uint64		enable_mask = PGS_BITMAPSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo));
@@ -1075,6 +1090,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 
 	run_cost += cpu_run_cost;
@@ -1083,7 +1100,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = enable_bitmapscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1240,6 +1258,7 @@ cost_tidscan(Path *path, PlannerInfo *root,
 	double		ntuples;
 	ListCell   *l;
 	double		spc_random_page_cost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1261,10 +1280,10 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 		/*
 		 * We must use a TID scan for CurrentOfExpr; in any other case, we
-		 * should be generating a TID scan only if enable_tidscan=true. Also,
-		 * if CurrentOfExpr is the qual, there should be only one.
+		 * should be generating a TID scan only if TID scans are allowed.
+		 * Also, if CurrentOfExpr is the qual, there should be only one.
 		 */
-		Assert(enable_tidscan || IsA(qual, CurrentOfExpr));
+		Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0 || IsA(qual, CurrentOfExpr));
 		Assert(list_length(tidquals) == 1 || !IsA(qual, CurrentOfExpr));
 
 		if (IsA(qual, ScalarArrayOpExpr))
@@ -1316,10 +1335,14 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 	/*
 	 * There are assertions above verifying that we only reach this function
-	 * either when enable_tidscan=true or when the TID scan is the only legal
-	 * path, so it's safe to set disabled_nodes to zero here.
+	 * either when baserel->pgs_mask includes PGS_TIDSCAN or when the TID scan
+	 * is the only legal path, so we only need to consider the effects of
+	 * PGS_CONSIDER_NONPARTIAL here.
 	 */
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1349,6 +1372,7 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	double		nseqpages;
 	double		spc_random_page_cost;
 	double		spc_seq_page_cost;
+	uint64		enable_mask = PGS_TIDSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1412,8 +1436,15 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	/* we should not generate this path type when enable_tidscan=false */
-	Assert(enable_tidscan);
+	/*
+	 * We should not generate this path type when PGS_TIDSCAN is unset, but we
+	 * might need to disable this path due to PGS_CONSIDER_NONPARTIAL.
+	 */
+	Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0);
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->disabled_nodes = 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
@@ -1437,6 +1468,7 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	List	   *qpquals;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are subqueries */
 	Assert(baserel->relid > 0);
@@ -1467,7 +1499,10 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	 * SubqueryScan node, plus cpu_tuple_cost to account for selection and
 	 * projection overhead.
 	 */
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	if (path->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ (((baserel->pgs_mask & enable_mask) != enable_mask) ? 1 : 0);
 	path->path.startup_cost = path->subpath->startup_cost;
 	path->path.total_cost = path->subpath->total_cost;
 
@@ -1518,6 +1553,7 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1558,7 +1594,10 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1580,6 +1619,7 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1615,7 +1655,10 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1635,6 +1678,7 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are values lists */
 	Assert(baserel->relid > 0);
@@ -1663,7 +1707,10 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1686,6 +1733,7 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are CTEs */
 	Assert(baserel->relid > 0);
@@ -1711,7 +1759,10 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1728,6 +1779,7 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are Tuplestores */
 	Assert(baserel->relid > 0);
@@ -1749,7 +1801,10 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	cpu_per_tuple += cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1766,6 +1821,7 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to RTE_RESULT base relations */
 	Assert(baserel->relid > 0);
@@ -1784,7 +1840,10 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1802,6 +1861,7 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	Cost		startup_cost;
 	Cost		total_cost;
 	double		total_rows;
+	uint64		enable_mask = 0;
 
 	/* We probably have decent estimates for the non-recursive term */
 	startup_cost = nrterm->startup_cost;
@@ -1824,7 +1884,10 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	 */
 	total_cost += cpu_tuple_cost * total_rows;
 
-	runion->disabled_nodes = nrterm->disabled_nodes + rterm->disabled_nodes;
+	if (runion->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	runion->disabled_nodes =
+		(runion->parent->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	runion->startup_cost = startup_cost;
 	runion->total_cost = total_cost;
 	runion->rows = total_rows;
@@ -2094,7 +2157,11 @@ cost_incremental_sort(Path *path,
 
 	path->rows = input_tuples;
 
-	/* should not generate these paths when enable_incremental_sort=false */
+	/*
+	 * We should not generate these paths when enable_incremental_sort=false.
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	Assert(enable_incremental_sort);
 	path->disabled_nodes = input_disabled_nodes;
 
@@ -2132,6 +2199,10 @@ cost_sort(Path *path, PlannerInfo *root,
 
 	startup_cost += input_cost;
 
+	/*
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	path->rows = tuples;
 	path->disabled_nodes = input_disabled_nodes + (enable_sort ? 0 : 1);
 	path->startup_cost = startup_cost;
@@ -2223,9 +2294,15 @@ append_nonpartial_cost(List *subpaths, int numpaths, int parallel_workers)
 void
 cost_append(AppendPath *apath, PlannerInfo *root)
 {
+	RelOptInfo *rel = apath->path.parent;
 	ListCell   *l;
+	uint64		enable_mask = PGS_APPEND;
+
+	if (apath->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	apath->path.disabled_nodes = 0;
+	apath->path.disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	apath->path.startup_cost = 0;
 	apath->path.total_cost = 0;
 	apath->path.rows = 0;
@@ -2435,11 +2512,16 @@ cost_merge_append(Path *path, PlannerInfo *root,
 				  Cost input_startup_cost, Cost input_total_cost,
 				  double tuples)
 {
+	RelOptInfo *rel = path->parent;
 	Cost		startup_cost = 0;
 	Cost		run_cost = 0;
 	Cost		comparison_cost;
 	double		N;
 	double		logN;
+	uint64		enable_mask = PGS_MERGE_APPEND;
+
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/*
 	 * Avoid log(0)...
@@ -2462,7 +2544,9 @@ cost_merge_append(Path *path, PlannerInfo *root,
 	 */
 	run_cost += cpu_tuple_cost * APPEND_CPU_COST_MULTIPLIER * tuples;
 
-	path->disabled_nodes = input_disabled_nodes;
+	path->disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
+	path->disabled_nodes += input_disabled_nodes;
 	path->startup_cost = startup_cost + input_startup_cost;
 	path->total_cost = startup_cost + run_cost + input_total_cost;
 }
@@ -2481,7 +2565,7 @@ cost_merge_append(Path *path, PlannerInfo *root,
  */
 void
 cost_material(Path *path,
-			  int input_disabled_nodes,
+			  bool enabled, int input_disabled_nodes,
 			  Cost input_startup_cost, Cost input_total_cost,
 			  double tuples, int width)
 {
@@ -2490,6 +2574,11 @@ cost_material(Path *path,
 	double		nbytes = relation_byte_size(tuples, width);
 	double		work_mem_bytes = work_mem * (Size) 1024;
 
+	if (path->parallel_workers == 0 &&
+		path->parent != NULL &&
+		(path->parent->pgs_mask & PGS_CONSIDER_NONPARTIAL) == 0)
+		enabled = false;
+
 	path->rows = tuples;
 
 	/*
@@ -2519,7 +2608,7 @@ cost_material(Path *path,
 		run_cost += seq_page_cost * npages;
 	}
 
-	path->disabled_nodes = input_disabled_nodes + (enable_material ? 0 : 1);
+	path->disabled_nodes = input_disabled_nodes + (enabled ? 0 : 1);
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -3271,7 +3360,7 @@ cost_group(Path *path, PlannerInfo *root,
  */
 void
 initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
-					  JoinType jointype,
+					  JoinType jointype, uint64 enable_mask,
 					  Path *outer_path, Path *inner_path,
 					  JoinPathExtraData *extra)
 {
@@ -3285,7 +3374,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Cost		inner_rescan_run_cost;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_nestloop ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
@@ -3685,7 +3774,19 @@ initial_cost_mergejoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Assert(outerstartsel <= outerendsel);
 	Assert(innerstartsel <= innerendsel);
 
-	disabled_nodes = enable_mergejoin ? 0 : 1;
+	/*
+	 * We don't decide whether to materialize the inner path until we get to
+	 * final_cost_mergejoin(), so we don't know whether to check the pgs_mask
+	 * again PGS_MERGEJOIN_PLAIN or PGS_MERGEJOIN_MATERIALIZE. Instead, we
+	 * just account for any child nodes here and assume that this node is not
+	 * itslef disabled; we can sort out the details in final_cost_mergejoin().
+	 *
+	 * (We could be more precise here by setting disabled_nodes to 1 at this
+	 * stage if both PGS_MERGEJOIN_PLAIN and PGS_MERGEJOIN_MATERIALIZE are
+	 * disabled, but that seems to against the idea of making this function
+	 * produce a quick, optimistic approximation of the final cost.)
+	 */
+	disabled_nodes = 0;
 
 	/* cost of source data */
 
@@ -3864,9 +3965,7 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	double		mergejointuples,
 				rescannedtuples;
 	double		rescanratio;
-
-	/* Set the number of disabled nodes. */
-	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+	uint64		enable_mask = 0;
 
 	/* Protect some assumptions below that rowcounts aren't zero */
 	if (inner_path_rows <= 0)
@@ -3996,16 +4095,20 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 		path->materialize_inner = false;
 
 	/*
-	 * Prefer materializing if it looks cheaper, unless the user has asked to
-	 * suppress materialization.
+	 * If merge joins with materialization are enabled, then choose
+	 * materialization if either (a) it looks cheaper or (b) merge joins
+	 * without materialization are disabled.
 	 */
-	else if (enable_material && mat_inner_cost < bare_inner_cost)
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 (mat_inner_cost < bare_inner_cost ||
+			  (extra->pgs_mask & PGS_MERGEJOIN_PLAIN) == 0))
 		path->materialize_inner = true;
 
 	/*
-	 * Even if materializing doesn't look cheaper, we *must* do it if the
-	 * inner path is to be used directly (without sorting) and it doesn't
-	 * support mark/restore.
+	 * Regardless of what plan shapes are enabled and what the costs seem to
+	 * be, we *must* materialize it if the inner path is to be used directly
+	 * (without sorting) and it doesn't support mark/restore. Planner failure
+	 * is not an option!
 	 *
 	 * Since the inner side must be ordered, and only Sorts and IndexScans can
 	 * create order to begin with, and they both support mark/restore, you
@@ -4013,10 +4116,6 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * merge joins can *preserve* the order of their inputs, so they can be
 	 * selected as the input of a mergejoin, and they don't support
 	 * mark/restore at present.
-	 *
-	 * We don't test the value of enable_material here, because
-	 * materialization is required for correctness in this case, and turning
-	 * it off does not entitle us to deliver an invalid plan.
 	 */
 	else if (innersortkeys == NIL &&
 			 !ExecSupportsMarkRestore(inner_path))
@@ -4030,10 +4129,11 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * though.
 	 *
 	 * Since materialization is a performance optimization in this case,
-	 * rather than necessary for correctness, we skip it if enable_material is
-	 * off.
+	 * rather than necessary for correctness, we skip it if materialization is
+	 * switched off.
 	 */
-	else if (enable_material && innersortkeys != NIL &&
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 innersortkeys != NIL &&
 			 relation_byte_size(inner_path_rows,
 								inner_path->pathtarget->width) >
 			 work_mem * (Size) 1024)
@@ -4041,11 +4141,29 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	else
 		path->materialize_inner = false;
 
-	/* Charge the right incremental cost for the chosen case */
+	/* Get the number of disabled nodes, not yet including this one. */
+	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+
+	/*
+	 * Charge the right incremental cost for the chosen case, and update
+	 * enable_mask as appropriate.
+	 */
 	if (path->materialize_inner)
+	{
 		run_cost += mat_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
 	else
+	{
 		run_cost += bare_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_PLAIN;
+	}
+
+	/* Incremental count of disabled nodes if this node is disabled. */
+	if (path->jpath.path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	if ((extra->pgs_mask & enable_mask) != enable_mask)
+		++path->jpath.path.disabled_nodes;
 
 	/* CPU costs */
 
@@ -4183,9 +4301,13 @@ initial_cost_hashjoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	int			numbatches;
 	int			num_skew_mcvs;
 	size_t		space_allowed;	/* unused */
+	uint64		enable_mask = PGS_HASHJOIN;
+
+	if (outer_path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_hashjoin ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index edc6d2ac1d3..a701c847cb5 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -2233,8 +2233,8 @@ check_index_only(RelOptInfo *rel, IndexOptInfo *index)
 	ListCell   *lc;
 	int			i;
 
-	/* Index-only scans must be enabled */
-	if (!enable_indexonlyscan)
+	/* If we're not allowed to consider index-only scans, give up now */
+	if ((rel->pgs_mask & PGS_CONSIDER_INDEXONLY) == 0)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index ea5b6415186..388d8456ff6 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -29,8 +29,9 @@
 #include "utils/lsyscache.h"
 #include "utils/typcache.h"
 
-/* Hook for plugins to get control in add_paths_to_joinrel() */
+/* Hooks for plugins to get control in add_paths_to_joinrel() */
 set_join_pathlist_hook_type set_join_pathlist_hook = NULL;
+join_path_setup_hook_type join_path_setup_hook = NULL;
 
 /*
  * Paths parameterized by a parent rel can be considered to be parameterized
@@ -151,6 +152,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	extra.mergeclause_list = NIL;
 	extra.sjinfo = sjinfo;
 	extra.param_source_rels = NULL;
+	extra.pgs_mask = joinrel->pgs_mask;
 
 	/*
 	 * See if the inner relation is provably unique for this outer rel.
@@ -207,13 +209,38 @@ add_paths_to_joinrel(PlannerInfo *root,
 	if (jointype == JOIN_UNIQUE_OUTER || jointype == JOIN_UNIQUE_INNER)
 		jointype = JOIN_INNER;
 
+	/*
+	 * Give extensions a chance to take control. In particular, an extension
+	 * might want to modify extra.pgs_mask. It's possible to override pgs_mask
+	 * on a query-wide basis using join_search_hook, or for a particular
+	 * relation using joinrel_setup_hook, but extensions that want to provide
+	 * different advice for the same joinrel based on the choice of innerrel
+	 * and outerrel will need to use this hook.
+	 *
+	 * A very simple way for an extension to use this hook is to set
+	 * extra.pgs_mask = 0, if it simply doesn't want any of the paths
+	 * generated by this call to add_paths_to_joinrel() to be selected. An
+	 * extension could use this technique to constrain the join order, since
+	 * it could thereby arrange to reject all paths from join orders that it
+	 * does not like. An extension can also selectively clear bits from
+	 * extra.pgs_mask to rule out specific techniques for specific joins, or
+	 * even replace the mask entirely.
+	 *
+	 * NB: Below this point, this function should be careful to reference
+	 * extra.pgs_mask rather than rel->pgs_mask to avoid disregarding any
+	 * changes made by the hook we're about to call.
+	 */
+	if (join_path_setup_hook)
+		join_path_setup_hook(root, joinrel, outerrel, innerrel,
+							 jointype, &extra);
+
 	/*
 	 * Find potential mergejoin clauses.  We can skip this if we are not
 	 * interested in doing a mergejoin.  However, mergejoin may be our only
-	 * way of implementing a full outer join, so override enable_mergejoin if
-	 * it's a full join.
+	 * way of implementing a full outer join, so in that case we don't care
+	 * whether mergejoins are disabled.
 	 */
-	if (enable_mergejoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_MERGEJOIN_ANY) != 0 || jointype == JOIN_FULL)
 		extra.mergeclause_list = select_mergejoin_clauses(root,
 														  joinrel,
 														  outerrel,
@@ -321,10 +348,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 
 	/*
 	 * 4. Consider paths where both outer and inner relations must be hashed
-	 * before being joined.  As above, disregard enable_hashjoin for full
-	 * joins, because there may be no other alternative.
+	 * before being joined.  As above, when it's a full join, we must try this
+	 * even when the path type is disabled, because it may be our only option.
 	 */
-	if (enable_hashjoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_HASHJOIN) != 0 || jointype == JOIN_FULL)
 		hash_inner_and_outer(root, joinrel, outerrel, innerrel,
 							 jointype, &extra);
 
@@ -333,7 +360,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	 * to the same server and assigned to the same user to check access
 	 * permissions as, give the FDW a chance to push down joins.
 	 */
-	if (joinrel->fdwroutine &&
+	if ((extra.pgs_mask & PGS_FOREIGNJOIN) != 0 && joinrel->fdwroutine &&
 		joinrel->fdwroutine->GetForeignJoinPaths)
 		joinrel->fdwroutine->GetForeignJoinPaths(root, joinrel,
 												 outerrel, innerrel,
@@ -342,8 +369,13 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * 6. Finally, give extensions a chance to manipulate the path list.  They
 	 * could add new paths (such as CustomPaths) by calling add_path(), or
-	 * add_partial_path() if parallel aware.  They could also delete or modify
-	 * paths added by the core code.
+	 * add_partial_path() if parallel aware.
+	 *
+	 * In theory, extensions could also use this hook to delete or modify
+	 * paths added by the core code, but in practice this is difficult to make
+	 * work, since it's too late to get back any paths that have already been
+	 * discarded by add_path() or add_partial_path(). If you're trying to
+	 * suppress paths, consider using join_path_setup_hook instead.
 	 */
 	if (set_join_pathlist_hook)
 		set_join_pathlist_hook(root, joinrel, outerrel, innerrel,
@@ -690,7 +722,7 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel,
 	List	   *ph_lateral_vars;
 
 	/* Obviously not if it's disabled */
-	if (!enable_memoize)
+	if ((extra->pgs_mask & PGS_NESTLOOP_MEMOIZE) == 0)
 		return NULL;
 
 	/*
@@ -845,6 +877,7 @@ try_nestloop_path(PlannerInfo *root,
 				  Path *inner_path,
 				  List *pathkeys,
 				  JoinType jointype,
+				  uint64 nestloop_subtype,
 				  JoinPathExtraData *extra)
 {
 	Relids		required_outer;
@@ -927,6 +960,7 @@ try_nestloop_path(PlannerInfo *root,
 	 * methodology worthwhile.
 	 */
 	initial_cost_nestloop(root, &workspace, jointype,
+						  nestloop_subtype | PGS_CONSIDER_NONPARTIAL,
 						  outer_path, inner_path, extra);
 
 	if (add_path_precheck(joinrel, workspace.disabled_nodes,
@@ -964,6 +998,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 						  Path *inner_path,
 						  List *pathkeys,
 						  JoinType jointype,
+						  uint64 nestloop_subtype,
 						  JoinPathExtraData *extra)
 {
 	JoinCostWorkspace workspace;
@@ -1011,7 +1046,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 	 * Before creating a path, get a quick lower bound on what it is likely to
 	 * cost.  Bail out right away if it looks terrible.
 	 */
-	initial_cost_nestloop(root, &workspace, jointype,
+	initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype,
 						  outer_path, inner_path, extra);
 	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
 								   workspace.total_cost, pathkeys))
@@ -1859,14 +1894,14 @@ match_unsorted_outer(PlannerInfo *root,
 	if (nestjoinOK)
 	{
 		/*
-		 * Consider materializing the cheapest inner path, unless
-		 * enable_material is off or the path in question materializes its
-		 * output anyway.
+		 * Consider materializing the cheapest inner path, unless that is
+		 * disabled or the path in question materializes its output anyway.
 		 */
-		if (enable_material && inner_cheapest_total != NULL &&
+		if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+			inner_cheapest_total != NULL &&
 			!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 			matpath = (Path *)
-				create_material_path(innerrel, inner_cheapest_total);
+				create_material_path(innerrel, inner_cheapest_total, true);
 	}
 
 	foreach(lc1, outerrel->pathlist)
@@ -1909,6 +1944,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  innerpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_PLAIN,
 								  extra);
 
 				/*
@@ -1925,6 +1961,7 @@ match_unsorted_outer(PlannerInfo *root,
 									  mpath,
 									  merge_pathkeys,
 									  jointype,
+									  PGS_NESTLOOP_MEMOIZE,
 									  extra);
 			}
 
@@ -1936,6 +1973,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  matpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_MATERIALIZE,
 								  extra);
 		}
 
@@ -2052,16 +2090,17 @@ consider_parallel_nestloop(PlannerInfo *root,
 
 	/*
 	 * Consider materializing the cheapest inner path, unless: 1)
-	 * enable_material is off, 2) the cheapest inner path is not
+	 * materialization is disabled here, 2) the cheapest inner path is not
 	 * parallel-safe, 3) the cheapest inner path is parameterized by the outer
 	 * rel, or 4) the cheapest inner path materializes its output anyway.
 	 */
-	if (enable_material && inner_cheapest_total->parallel_safe &&
+	if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+		inner_cheapest_total->parallel_safe &&
 		!PATH_PARAM_BY_REL(inner_cheapest_total, outerrel) &&
 		!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 	{
 		matpath = (Path *)
-			create_material_path(innerrel, inner_cheapest_total);
+			create_material_path(innerrel, inner_cheapest_total, true);
 		Assert(matpath->parallel_safe);
 	}
 
@@ -2091,7 +2130,8 @@ consider_parallel_nestloop(PlannerInfo *root,
 				continue;
 
 			try_partial_nestloop_path(root, joinrel, outerpath, innerpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_PLAIN, extra);
 
 			/*
 			 * Try generating a memoize path and see if that makes the nested
@@ -2102,13 +2142,15 @@ consider_parallel_nestloop(PlannerInfo *root,
 									 extra);
 			if (mpath != NULL)
 				try_partial_nestloop_path(root, joinrel, outerpath, mpath,
-										  pathkeys, jointype, extra);
+										  pathkeys, jointype,
+										  PGS_NESTLOOP_MEMOIZE, extra);
 		}
 
 		/* Also consider materialized form of the cheapest inner path */
 		if (matpath != NULL)
 			try_partial_nestloop_path(root, joinrel, outerpath, matpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_MATERIALIZE, extra);
 	}
 }
 
diff --git a/src/backend/optimizer/path/tidpath.c b/src/backend/optimizer/path/tidpath.c
index 2bfb338b81c..639a0d3cadb 100644
--- a/src/backend/optimizer/path/tidpath.c
+++ b/src/backend/optimizer/path/tidpath.c
@@ -500,18 +500,19 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	List	   *tidquals;
 	List	   *tidrangequals;
 	bool		isCurrentOf;
+	bool		enabled = (rel->pgs_mask & PGS_TIDSCAN) != 0;
 
 	/*
 	 * If any suitable quals exist in the rel's baserestrict list, generate a
 	 * plain (unparameterized) TidPath with them.
 	 *
-	 * We skip this when enable_tidscan = false, except when the qual is
+	 * We skip this when TID scans are disabled, except when the qual is
 	 * CurrentOfExpr. In that case, a TID scan is the only correct path.
 	 */
 	tidquals = TidQualFromRestrictInfoList(root, rel->baserestrictinfo, rel,
 										   &isCurrentOf);
 
-	if (tidquals != NIL && (enable_tidscan || isCurrentOf))
+	if (tidquals != NIL && (enabled || isCurrentOf))
 	{
 		/*
 		 * This path uses no join clauses, but it could still have required
@@ -533,7 +534,7 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	}
 
 	/* Skip the rest if TID scans are disabled. */
-	if (!enable_tidscan)
+	if (!enabled)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 88b4c5901b0..f47f9aab47a 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -6526,6 +6526,7 @@ materialize_finished_plan(Plan *subplan)
 
 	/* Set cost data */
 	cost_material(&matpath,
+				  enable_material,
 				  subplan->disabled_nodes,
 				  subplan->startup_cost,
 				  subplan->total_cost,
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 8b1ab847f39..e2683b2481f 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -462,6 +462,53 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 		tuple_fraction = 0.0;
 	}
 
+	/*
+	 * Compute the initial path generation strategy mask.
+	 *
+	 * Some strategies, such as PGS_FOREIGNJOIN, have no corresponding enable_*
+	 * GUC, and so the corresponding bits are always set in the default
+	 * strategy mask.
+	 *
+	 * It may seem surprising that enable_indexscan sets both PGS_INDEXSCAN
+	 * and PGS_INDEXONLYSCAN. However, the historical behavior of this GUC
+	 * corresponds to this exactly: enable_indexscan=off disables both
+	 * index-scan and index-only scan paths, whereas enable_indexonlyscan=off
+	 * converts the index-only scan paths that we would have considered into
+	 * index scan paths.
+	 */
+	glob->default_pgs_mask = PGS_APPEND | PGS_MERGE_APPEND | PGS_FOREIGNJOIN |
+		PGS_GATHER | PGS_CONSIDER_NONPARTIAL;
+	if (enable_tidscan)
+		glob->default_pgs_mask |= PGS_TIDSCAN;
+	if (enable_seqscan)
+		glob->default_pgs_mask |= PGS_SEQSCAN;
+	if (enable_indexscan)
+		glob->default_pgs_mask |= PGS_INDEXSCAN | PGS_INDEXONLYSCAN;
+	if (enable_indexonlyscan)
+		glob->default_pgs_mask |= PGS_CONSIDER_INDEXONLY;
+	if (enable_bitmapscan)
+		glob->default_pgs_mask |= PGS_BITMAPSCAN;
+	if (enable_mergejoin)
+	{
+		glob->default_pgs_mask |= PGS_MERGEJOIN_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
+	if (enable_nestloop)
+	{
+		glob->default_pgs_mask |= PGS_NESTLOOP_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MATERIALIZE;
+		if (enable_memoize)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MEMOIZE;
+	}
+	if (enable_hashjoin)
+		glob->default_pgs_mask |= PGS_HASHJOIN;
+	if (enable_gathermerge)
+		glob->default_pgs_mask |= PGS_GATHER_MERGE;
+	if (enable_partitionwise_join)
+		glob->default_pgs_mask |= PGS_CONSIDER_PARTITIONWISE;
+
 	/* Allow plugins to take control after we've initialized "glob" */
 	if (planner_setup_hook)
 		(*planner_setup_hook) (glob, parse, query_string, &tuple_fraction, es);
@@ -3954,6 +4001,9 @@ make_grouping_rel(PlannerInfo *root, RelOptInfo *input_rel,
 		is_parallel_safe(root, (Node *) havingQual))
 		grouped_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed */
+	grouped_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the grouped rel.
 	 */
@@ -5348,6 +5398,9 @@ create_ordered_paths(PlannerInfo *root,
 	if (input_rel->consider_parallel && target_parallel_safe)
 		ordered_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed. */
+	ordered_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the ordered_rel.
 	 */
@@ -7428,6 +7481,7 @@ create_partial_grouping_paths(PlannerInfo *root,
 											grouped_rel->relids);
 	partially_grouped_rel->consider_parallel =
 		grouped_rel->consider_parallel;
+	partially_grouped_rel->pgs_mask = grouped_rel->pgs_mask;
 	partially_grouped_rel->reloptkind = grouped_rel->reloptkind;
 	partially_grouped_rel->serverid = grouped_rel->serverid;
 	partially_grouped_rel->userid = grouped_rel->userid;
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index c0a9811b130..eb57f0538ba 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1658,7 +1658,7 @@ create_group_result_path(PlannerInfo *root, RelOptInfo *rel,
  *	  pathnode.
  */
 MaterialPath *
-create_material_path(RelOptInfo *rel, Path *subpath)
+create_material_path(RelOptInfo *rel, Path *subpath, bool enabled)
 {
 	MaterialPath *pathnode = makeNode(MaterialPath);
 
@@ -1677,6 +1677,7 @@ create_material_path(RelOptInfo *rel, Path *subpath)
 	pathnode->subpath = subpath;
 
 	cost_material(&pathnode->path,
+				  enabled,
 				  subpath->disabled_nodes,
 				  subpath->startup_cost,
 				  subpath->total_cost,
@@ -1729,8 +1730,15 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath,
 	pathnode->est_unique_keys = 0.0;
 	pathnode->est_hit_ratio = 0.0;
 
-	/* we should not generate this path type when enable_memoize=false */
-	Assert(enable_memoize);
+	/*
+	 * We should not be asked to generate this path type when memoization is
+	 * disabled, so set our count of disabled nodes equal to the subpath's
+	 * count.
+	 *
+	 * It would be nice to also Assert that memoization is enabled, but the
+	 * value of enable_memoize is not controlling: what we would need to check
+	 * is that the JoinPathExtraData's pgs_mask included PGS_NESTLOOP_MEMOIZE.
+	 */
 	pathnode->path.disabled_nodes = subpath->disabled_nodes;
 
 	/*
@@ -3964,13 +3972,16 @@ reparameterize_path(PlannerInfo *root, Path *path,
 			{
 				MaterialPath *mpath = (MaterialPath *) path;
 				Path	   *spath = mpath->subpath;
+				bool		enabled;
 
 				spath = reparameterize_path(root, spath,
 											required_outer,
 											loop_count);
+				enabled =
+					(mpath->path.disabled_nodes <= spath->disabled_nodes);
 				if (spath == NULL)
 					return NULL;
-				return (Path *) create_material_path(rel, spath);
+				return (Path *) create_material_path(rel, spath, enabled);
 			}
 		case T_Memoize:
 			{
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index d950bd93002..ffd7bb3b221 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -557,6 +557,9 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 	 * Allow a plugin to editorialize on the info we obtained from the
 	 * catalogs.  Actions might include altering the assumed relation size,
 	 * removing an index, or adding a hypothetical index to the indexlist.
+	 *
+	 * An extension can also modify rel->pgs_mask here to control path
+	 * generation.
 	 */
 	if (get_relation_info_hook)
 		(*get_relation_info_hook) (root, relationObjectId, inhparent, rel);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 1158bc194c3..034d0c9c87a 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -47,6 +47,9 @@ typedef struct JoinHashEntry
 	RelOptInfo *join_rel;
 } JoinHashEntry;
 
+/* Hook for plugins to get control during joinrel setup */
+joinrel_setup_hook_type joinrel_setup_hook = NULL;
+
 static void build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 								RelOptInfo *input_rel,
 								SpecialJoinInfo *sjinfo,
@@ -225,6 +228,7 @@ build_simple_rel(PlannerInfo *root, int relid, RelOptInfo *parent)
 	rel->consider_startup = (root->tuple_fraction > 0);
 	rel->consider_param_startup = false;	/* might get changed later */
 	rel->consider_parallel = false; /* might get changed later */
+	rel->pgs_mask = root->glob->default_pgs_mask;
 	rel->reltarget = create_empty_pathtarget();
 	rel->pathlist = NIL;
 	rel->ppilist = NIL;
@@ -822,6 +826,7 @@ build_join_rel(PlannerInfo *root,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -934,10 +939,6 @@ build_join_rel(PlannerInfo *root,
 	 */
 	joinrel->has_eclass_joins = has_relevant_eclass_joinclause(root, joinrel);
 
-	/* Store the partition information. */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/*
 	 * Set estimates of the joinrel's size.
 	 */
@@ -963,6 +964,18 @@ build_join_rel(PlannerInfo *root,
 		is_parallel_safe(root, (Node *) joinrel->reltarget->exprs))
 		joinrel->consider_parallel = true;
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Store the partition information. */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* Add the joinrel to the PlannerInfo. */
 	add_join_rel(root, joinrel);
 
@@ -1019,6 +1032,7 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -1102,10 +1116,6 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	 */
 	joinrel->has_eclass_joins = parent_joinrel->has_eclass_joins;
 
-	/* Is the join between partitions itself partitioned? */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/* Child joinrel is parallel safe if parent is parallel safe. */
 	joinrel->consider_parallel = parent_joinrel->consider_parallel;
 
@@ -1113,6 +1123,20 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	set_joinrel_size_estimates(root, joinrel, outer_rel, inner_rel,
 							   sjinfo, restrictlist);
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel,
+	 * although the latter would be better done in the parent joinrel rather
+	 * than here.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Is the join between partitions itself partitioned? */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* We build the join only once. */
 	Assert(!find_join_rel(root, joinrel->relids));
 
@@ -1602,6 +1626,7 @@ fetch_upper_rel(PlannerInfo *root, UpperRelationKind kind, Relids relids)
 	upperrel = makeNode(RelOptInfo);
 	upperrel->reloptkind = RELOPT_UPPER_REL;
 	upperrel->relids = bms_copy(relids);
+	upperrel->pgs_mask = root->glob->default_pgs_mask;
 
 	/* cheap startup cost is interesting iff not all tuples to be retrieved */
 	upperrel->consider_startup = (root->tuple_fraction > 0);
@@ -2118,7 +2143,7 @@ build_joinrel_partition_info(PlannerInfo *root,
 	PartitionScheme part_scheme;
 
 	/* Nothing to do if partitionwise join technique is disabled. */
-	if (!enable_partitionwise_join)
+	if ((joinrel->pgs_mask & PGS_CONSIDER_PARTITIONWISE) == 0)
 	{
 		Assert(!IS_PARTITIONED_REL(joinrel));
 		return;
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 75a70489e5a..4746d3c43c4 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -22,6 +22,79 @@
 #include "nodes/parsenodes.h"
 #include "storage/block.h"
 
+/*
+ * Path generation strategies.
+ *
+ * These constants are used to specify the set of strategies that the planner
+ * should use, either for the query as a whole or for a specific baserel or
+ * joinrel. The various planner-related enable_* GUCs are used to set the
+ * PlannerGlobal's default_pgs_mask, and that in turn is used to set each
+ * RelOptInfo's pgs_mask. In both cases, extensions can use hooks to modify the
+ * default value.  Not every strategy listed here has a corresponding enable_*
+ * GUC; those that don't are always allowed unless disabled by an extension.
+ * Not all strategies are relevant for every RelOptInfo; e.g. PGS_SEQSCAN
+ * doesn't affect joinrels one way or the other.
+ *
+ * In most cases, disabling a path generation strategy merely means that any
+ * paths generated using that strategy are marked as disabled, but in some
+ * cases, path generation is skipped altogether. The latter strategy is only
+ * permissible when it can't result in planner failure -- for instance, we
+ * couldn't do this for sequential scans on a plain rel, because there might
+ * not be any other possible path. Nevertheless, the behaviors in each
+ * individual case are to some extent the result of historical accident,
+ * chosen to match the preexisting behaviors of the enable_* GUCs.
+ *
+ * In a few cases, we have more than one bit for the same strategy, controlling
+ * different aspects of the planner behavior. When PGS_CONSIDER_INDEXONLY is
+ * unset, we don't even consider index-only scans, and any such scans that
+ * would have been generated become index scans instead. On the other hand,
+ * unsetting PGS_INDEXSCAN or PGS_INDEXONLYSCAN causes generated paths of the
+ * corresponding types to be marked as disabled. Similarly, unsetting
+ * PGS_CONSIDER_PARTITIONWISE prevents any sort of thinking about partitionwise
+ * joins for the current rel, which incidentally will preclude higher-level
+ * joinrels from building parititonwise paths using paths taken from the
+ * current rel's children. On the other hand, unsetting PGS_APPEND or
+ * PGS_MERGE_APPEND will only arrange to disable paths of the corresponding
+ * types if they are generated at the level of the current rel.
+ *
+ * Finally, unsetting PGS_CONSIDER_NONPARTIAL disables all non-partial paths
+ * except those that use Gather or Gather Merge. In most other cases, a
+ * plugin can nudge the planner toward a particular strategy by disabling
+ * all of the others, but that doesn't work here: unsetting PGS_SEQSCAN,
+ * for instance, would disable both partial and non-partial sequential scans.
+ */
+#define PGS_SEQSCAN					0x00000001
+#define PGS_INDEXSCAN				0x00000002
+#define PGS_INDEXONLYSCAN			0x00000004
+#define PGS_BITMAPSCAN				0x00000008
+#define PGS_TIDSCAN					0x00000010
+#define PGS_FOREIGNJOIN				0x00000020
+#define PGS_MERGEJOIN_PLAIN			0x00000040
+#define PGS_MERGEJOIN_MATERIALIZE	0x00000080
+#define PGS_NESTLOOP_PLAIN			0x00000100
+#define PGS_NESTLOOP_MATERIALIZE	0x00000200
+#define PGS_NESTLOOP_MEMOIZE		0x00000400
+#define PGS_HASHJOIN				0x00000800
+#define PGS_APPEND					0x00001000
+#define PGS_MERGE_APPEND			0x00002000
+#define PGS_GATHER					0x00004000
+#define PGS_GATHER_MERGE			0x00008000
+#define PGS_CONSIDER_INDEXONLY		0x00010000
+#define PGS_CONSIDER_PARTITIONWISE	0x00020000
+#define PGS_CONSIDER_NONPARTIAL		0x00040000
+
+/*
+ * Convenience macros for useful combination of the bits defined above.
+ */
+#define PGS_SCAN_ANY		\
+	(PGS_SEQSCAN | PGS_INDEXSCAN | PGS_INDEXONLYSCAN | PGS_BITMAPSCAN | \
+	 PGS_TIDSCAN)
+#define PGS_MERGEJOIN_ANY	\
+	(PGS_MERGEJOIN_PLAIN | PGS_MERGEJOIN_MATERIALIZE)
+#define PGS_NESTLOOP_ANY	\
+	(PGS_NESTLOOP_PLAIN | PGS_NESTLOOP_MATERIALIZE | PGS_NESTLOOP_MEMOIZE)
+#define PGS_JOIN_ANY		\
+	(PGS_FOREIGNJOIN | PGS_MERGEJOIN_ANY | PGS_NESTLOOP_ANY | PGS_HASHJOIN)
 
 /*
  * Relids
@@ -186,6 +259,9 @@ typedef struct PlannerGlobal
 	/* worst PROPARALLEL hazard level */
 	char		maxParallelHazard;
 
+	/* mask of allowed path generation strategies */
+	uint64		default_pgs_mask;
+
 	/* partition descriptors */
 	PartitionDirectory partition_directory pg_node_attr(read_write_ignore);
 
@@ -939,7 +1015,7 @@ typedef struct RelOptInfo
 	Cardinality rows;
 
 	/*
-	 * per-relation planner control flags
+	 * per-relation planner control
 	 */
 	/* keep cheap-startup-cost paths? */
 	bool		consider_startup;
@@ -947,6 +1023,8 @@ typedef struct RelOptInfo
 	bool		consider_param_startup;
 	/* consider parallel paths? */
 	bool		consider_parallel;
+	/* path generation strategy mask */
+	uint64		pgs_mask;
 
 	/*
 	 * default result targetlist for Paths scanning this relation; list of
@@ -3505,6 +3583,7 @@ typedef struct SemiAntiJoinFactors
  * sjinfo is extra info about special joins for selectivity estimation
  * semifactors is as shown above (only valid for SEMI/ANTI/inner_unique joins)
  * param_source_rels are OK targets for parameterization of result paths
+ * pgs_mask is a bitmask of PGS_* constants to limit the join strategy
  */
 typedef struct JoinPathExtraData
 {
@@ -3514,6 +3593,7 @@ typedef struct JoinPathExtraData
 	SpecialJoinInfo *sjinfo;
 	SemiAntiJoinFactors semifactors;
 	Relids		param_source_rels;
+	uint64		pgs_mask;
 } JoinPathExtraData;
 
 /*
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index b523bcda8f3..2d80462bece 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -125,7 +125,7 @@ extern void cost_merge_append(Path *path, PlannerInfo *root,
 							  Cost input_startup_cost, Cost input_total_cost,
 							  double tuples);
 extern void cost_material(Path *path,
-						  int input_disabled_nodes,
+						  bool enabled, int input_disabled_nodes,
 						  Cost input_startup_cost, Cost input_total_cost,
 						  double tuples, int width);
 extern void cost_agg(Path *path, PlannerInfo *root,
@@ -148,7 +148,7 @@ extern void cost_group(Path *path, PlannerInfo *root,
 					   double input_tuples);
 extern void initial_cost_nestloop(PlannerInfo *root,
 								  JoinCostWorkspace *workspace,
-								  JoinType jointype,
+								  JoinType jointype, uint64 enable_mask,
 								  Path *outer_path, Path *inner_path,
 								  JoinPathExtraData *extra);
 extern void final_cost_nestloop(PlannerInfo *root, NestPath *path,
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 4437248cb67..274cd41bab1 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -17,6 +17,14 @@
 #include "nodes/bitmapset.h"
 #include "nodes/pathnodes.h"
 
+/* Hook for plugins to get control during joinrel setup */
+typedef void (*joinrel_setup_hook_type) (PlannerInfo *root,
+										 RelOptInfo *joinrel,
+										 RelOptInfo *outer_rel,
+										 RelOptInfo *inner_rel,
+										 SpecialJoinInfo *sjinfo,
+										 List *restrictlist);
+extern PGDLLIMPORT joinrel_setup_hook_type joinrel_setup_hook;
 
 /*
  * prototypes for pathnode.c
@@ -84,7 +92,8 @@ extern GroupResultPath *create_group_result_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 PathTarget *target,
 												 List *havingqual);
-extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath);
+extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath,
+										  bool enabled);
 extern MemoizePath *create_memoize_path(PlannerInfo *root,
 										RelOptInfo *rel,
 										Path *subpath,
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index f6a62df0b43..61c1607f872 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -28,7 +28,14 @@ extern PGDLLIMPORT int min_parallel_table_scan_size;
 extern PGDLLIMPORT int min_parallel_index_scan_size;
 extern PGDLLIMPORT bool enable_group_by_reordering;
 
-/* Hook for plugins to get control in set_rel_pathlist() */
+/* Hooks for plugins to get control in set_rel_pathlist() */
+typedef void (*join_path_setup_hook_type) (PlannerInfo *root,
+										   RelOptInfo *joinrel,
+										   RelOptInfo *outerrel,
+										   RelOptInfo *innerrel,
+										   JoinType jointype,
+										   JoinPathExtraData *extra);
+extern PGDLLIMPORT join_path_setup_hook_type join_path_setup_hook;
 typedef void (*set_rel_pathlist_hook_type) (PlannerInfo *root,
 											RelOptInfo *rel,
 											Index rti,
-- 
2.51.0



  [application/octet-stream] v3-0002-Store-information-about-elided-nodes-in-the-final.patch (9.3K, 3-v3-0002-Store-information-about-elided-nodes-in-the-final.patch)
  download | inline diff:
From 94f6396adb94363b5c4bdfc0aea38e5593f82d36 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:42 -0400
Subject: [PATCH v3 2/6] Store information about elided nodes in the final
 plan.

An extension (or core code) might want to reconstruct the planner's
choice of join order from the final plan. To do so, it must be possible
to find all of the RTIs that were part of the join problem in that plan.
The previous commit, together with the earlier work in
8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0, is enough to let us match up
RTIs we see in the final plan with RTIs that we see during the planning
cycle, but we still have a problem if the planner decides to drop some
RTIs out of the final plan altogether.

To fix that, when setrefs.c removes a SubqueryScan, single-child Append,
or single-child MergeAppend from the final Plan tree, record the type of
the removed node and the RTIs that the removed node would have scanned
in the final plan tree. It would be natural to record this information
on the child of the removed plan node, but that would require adding
an additional pointer field to type Plan, which seems undesirable.
So, instead, store the information in a separate list that the
executor need never consult, and use the plan_node_id to identify
the plan node with which the removed node is logically associated.

Also, update pg_overexplain to display these details.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 39 ++++++++++++++
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/plan/setrefs.c          | 52 ++++++++++++++++++-
 src/include/nodes/pathnodes.h                 |  3 ++
 src/include/nodes/plannodes.h                 | 17 ++++++
 src/tools/pgindent/typedefs.list              |  1 +
 7 files changed, 114 insertions(+), 3 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 55d34666d87..ca9a23ea61f 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -452,6 +452,8 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
  Seq Scan on daucus vegetables
    Filter: (genus = 'daucus'::text)
    Scan RTI: 2
+   Elided Node Type: Append
+   Elided Node RTIs: 1
  RTI 1 (relation, inherited, in-from-clause):
    Eref: vegetables (id, name, genus)
    Relation: vegetables
@@ -465,7 +467,7 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 2
-(16 rows)
+(18 rows)
 
 -- Also test a case that involves a write.
 EXPLAIN (RANGE_TABLE, COSTS OFF)
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index 5dc707d69e3..fa907fa472e 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -191,6 +191,8 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 	 */
 	if (options->range_table)
 	{
+		bool		opened_elided_nodes = false;
+
 		switch (nodeTag(plan))
 		{
 			case T_SeqScan:
@@ -251,6 +253,43 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 			default:
 				break;
 		}
+
+		foreach_node(ElidedNode, n, es->pstmt->elidedNodes)
+		{
+			char	   *elidednodetag;
+
+			if (n->plan_node_id != plan->plan_node_id)
+				continue;
+
+			if (!opened_elided_nodes)
+			{
+				ExplainOpenGroup("Elided Nodes", "Elided Nodes", false, es);
+				opened_elided_nodes = true;
+			}
+
+			switch (n->elided_type)
+			{
+				case T_Append:
+					elidednodetag = "Append";
+					break;
+				case T_MergeAppend:
+					elidednodetag = "MergeAppend";
+					break;
+				case T_SubqueryScan:
+					elidednodetag = "SubqueryScan";
+					break;
+				default:
+					elidednodetag = psprintf("%d", n->elided_type);
+					break;
+			}
+
+			ExplainOpenGroup("Elided Node", NULL, true, es);
+			ExplainPropertyText("Elided Node Type", elidednodetag, es);
+			overexplain_bitmapset("Elided Node RTIs", n->relids, es);
+			ExplainCloseGroup("Elided Node", NULL, true, es);
+		}
+		if (opened_elided_nodes)
+			ExplainCloseGroup("Elided Nodes", "Elided Nodes", false, es);
 	}
 }
 
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 0e6b3f60f31..9d5262651e7 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -618,6 +618,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->paramExecTypes = glob->paramExecTypes;
 	/* utilityStmt should be null, but we might as well copy it */
 	result->utilityStmt = parse->utilityStmt;
+	result->elidedNodes = glob->elidedNodes;
 	result->stmt_location = parse->stmt_location;
 	result->stmt_len = parse->stmt_len;
 
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index adabae09a23..23a00d452b7 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -211,6 +211,9 @@ static List *set_windowagg_runcondition_references(PlannerInfo *root,
 												   List *runcondition,
 												   Plan *plan);
 
+static void record_elided_node(PlannerGlobal *glob, int plan_node_id,
+							   NodeTag elided_type, Bitmapset *relids);
+
 
 /*****************************************************************************
  *
@@ -1460,10 +1463,17 @@ set_subqueryscan_references(PlannerInfo *root,
 
 	if (trivial_subqueryscan(plan))
 	{
+		Index		scanrelid;
+
 		/*
 		 * We can omit the SubqueryScan node and just pull up the subplan.
 		 */
 		result = clean_up_removed_plan_level((Plan *) plan, plan->subplan);
+
+		/* Remember that we removed a SubqueryScan */
+		scanrelid = plan->scan.scanrelid + rtoffset;
+		record_elided_node(root->glob, plan->subplan->plan_node_id,
+						   T_SubqueryScan, bms_make_singleton(scanrelid));
 	}
 	else
 	{
@@ -1891,7 +1901,17 @@ set_append_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(aplan->appendplans);
 
 		if (p->parallel_aware == aplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) aplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) aplan, p);
+
+			/* Remember that we removed an Append */
+			record_elided_node(root->glob, p->plan_node_id, T_Append,
+							   offset_relid_set(aplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -1959,7 +1979,17 @@ set_mergeappend_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(mplan->mergeplans);
 
 		if (p->parallel_aware == mplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) mplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) mplan, p);
+
+			/* Remember that we removed a MergeAppend */
+			record_elided_node(root->glob, p->plan_node_id, T_MergeAppend,
+							   offset_relid_set(mplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -3774,3 +3804,21 @@ extract_query_dependencies_walker(Node *node, PlannerInfo *context)
 	return expression_tree_walker(node, extract_query_dependencies_walker,
 								  context);
 }
+
+/*
+ * Record some details about a node removed from the plan during setrefs
+ * procesing, for the benefit of code trying to reconstruct planner decisions
+ * from examination of the final plan tree.
+ */
+static void
+record_elided_node(PlannerGlobal *glob, int plan_node_id,
+				   NodeTag elided_type, Bitmapset *relids)
+{
+	ElidedNode *n = makeNode(ElidedNode);
+
+	n->plan_node_id = plan_node_id;
+	n->elided_type = elided_type;
+	n->relids = relids;
+
+	glob->elidedNodes = lappend(glob->elidedNodes, n);
+}
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index a3a800869df..cf3a16b8b0e 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -159,6 +159,9 @@ typedef struct PlannerGlobal
 	/* type OIDs for PARAM_EXEC Params */
 	List	   *paramExecTypes;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/* highest PlaceHolderVar ID assigned */
 	Index		lastPHId;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 1526dd2ec6b..5d0520d5e58 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -152,6 +152,9 @@ typedef struct PlannedStmt
 	/* non-null if this is utility stmt */
 	Node	   *utilityStmt;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/*
 	 * DefElem objects added by extensions, e.g. using planner_shutdown_hook
 	 *
@@ -1838,4 +1841,18 @@ typedef struct SubPlanRTInfo
 	bool		dummy;
 } SubPlanRTInfo;
 
+/*
+ * ElidedNode
+ *
+ * Information about nodes elided from the final plan tree: trivial subquery
+ * scans, and single-child Append and MergeAppend nodes.
+ */
+typedef struct ElidedNode
+{
+	NodeTag		type;
+	int			plan_node_id;
+	NodeTag		elided_type;
+	Bitmapset  *relids;
+} ElidedNode;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 60aa0b0937b..4ff47115ca8 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -698,6 +698,7 @@ EachState
 Edge
 EditableObjectType
 ElementsState
+ElidedNode
 EnableTimeoutParams
 EndDataPtrType
 EndDirectModify_function
-- 
2.51.0



  [application/octet-stream] v3-0001-Store-information-about-range-table-flattening-in.patch (7.9K, 4-v3-0001-Store-information-about-range-table-flattening-in.patch)
  download | inline diff:
From 1817e4cffb505d834316554cf42d42d1aef3a63d Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 12:00:18 -0400
Subject: [PATCH v3 1/6] Store information about range-table flattening in the
 final plan.

Suppose that we're currently planning a query and, when that same
query was previously planned and executed, we learned something about
how a certain table within that query should be planned. We want to
take note when that same table is being planned during the current
planning cycle, but this is difficult to do, because the RTI of the
table from the previous plan won't necessarily be equal to the RTI
that we see during the current planning cycle. This is because each
subquery has a separate range table during planning, but these are
flattened into one range table when constructing the final plan,
changing RTIs.

Commit 8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0 allows us to match up
subqueries seen in the previous planning cycles with the subqueries
currently being planned just by comparing textual names, but that's
not quite enough to let us deduce anything about individual tables,
because we don't know where each subquery's range table appears in
the final, flattened range table.

To fix that, store a list of SubPlanRTInfo objects in the final
planned statement, each including the name of the subplan, the offset
at which it begins in the flattened range table, and whether or not
it was a dummy subplan -- if it was, some RTIs may have been dropped
from the final range table, but also there's no need to control how
a dummy subquery gets planned. The toplevel subquery has no name and
always begins at rtoffset 0, so we make no entry for it.

This commit teaches pg_overexplain'e RANGE_TABLE option to make use
of this new data to display the subquery name for each range table
entry.

NOTE TO REVIEWERS: If there's a clean way to make pg_overexplain display
this information without the new infrastructure provided by this patch,
then this patch is unnecessary. I thought there would be a way to do
that, but I couldn't figure anything out: there seems to be nothing that
records in the final PlannedStmt where subquery's range table ends and
the next one begins. In practice, one could usually figure it out by
matching up tables by relation OID, but that's neither clean nor
theoretically sound.
---
 contrib/pg_overexplain/pg_overexplain.c | 36 +++++++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    |  1 +
 src/backend/optimizer/plan/setrefs.c    | 20 ++++++++++++++
 src/include/nodes/pathnodes.h           |  3 +++
 src/include/nodes/plannodes.h           | 17 ++++++++++++
 src/tools/pgindent/typedefs.list        |  1 +
 6 files changed, 78 insertions(+)

diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index bd70b6d9d5e..5dc707d69e3 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -395,6 +395,8 @@ static void
 overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 {
 	Index		rti;
+	ListCell   *lc_subrtinfo = list_head(plannedstmt->subrtinfos);
+	SubPlanRTInfo *rtinfo = NULL;
 
 	/* Open group, one entry per RangeTblEntry */
 	ExplainOpenGroup("Range Table", "Range Table", false, es);
@@ -405,6 +407,18 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 		RangeTblEntry *rte = rt_fetch(rti, plannedstmt->rtable);
 		char	   *kind = NULL;
 		char	   *relkind;
+		SubPlanRTInfo *next_rtinfo;
+
+		/* Advance to next SubRTInfo, if it's time. */
+		if (lc_subrtinfo != NULL)
+		{
+			next_rtinfo = lfirst(lc_subrtinfo);
+			if (rti > next_rtinfo->rtoffset)
+			{
+				rtinfo = next_rtinfo;
+				lc_subrtinfo = lnext(plannedstmt->subrtinfos, lc_subrtinfo);
+			}
+		}
 
 		/* NULL entries are possible; skip them */
 		if (rte == NULL)
@@ -469,6 +483,28 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 			ExplainPropertyBool("In From Clause", rte->inFromCl, es);
 		}
 
+		/*
+		 * Indicate which subplan is the origin of which RTE. Note dummy
+		 * subplans. Here again, we crunch more onto one line in text format.
+		 */
+		if (rtinfo != NULL)
+		{
+			if (es->format == EXPLAIN_FORMAT_TEXT)
+			{
+				if (!rtinfo->dummy)
+					ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				else
+					ExplainPropertyText("Subplan",
+										psprintf("%s (dummy)",
+												 rtinfo->plan_name), es);
+			}
+			else
+			{
+				ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				ExplainPropertyBool("Subplan Is Dummy", rtinfo->dummy, es);
+			}
+		}
+
 		/* rte->alias is optional; rte->eref is requested */
 		if (rte->alias != NULL)
 			overexplain_alias("Alias", rte->alias, es);
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index c4fd646b999..0e6b3f60f31 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -607,6 +607,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->unprunableRelids = bms_difference(glob->allRelids,
 											  glob->prunableRelids);
 	result->permInfos = glob->finalrteperminfos;
+	result->subrtinfos = glob->subrtinfos;
 	result->resultRelations = glob->resultRelations;
 	result->appendRelations = glob->appendRelations;
 	result->subplans = glob->subplans;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ccdc9bc264a..adabae09a23 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -399,6 +399,26 @@ add_rtes_to_flat_rtable(PlannerInfo *root, bool recursing)
 	Index		rti;
 	ListCell   *lc;
 
+	/*
+	 * Record enough information to make it possible for code that looks at
+	 * the final range table to understand how it was constructed. (If
+	 * finalrtable is still NIL, then this is the very topmost PlannerInfo,
+	 * which will always have plan_name == NULL and rtoffset == 0; we omit the
+	 * degenerate list entry.)
+	 */
+	if (root->glob->finalrtable != NIL)
+	{
+		SubPlanRTInfo *rtinfo = makeNode(SubPlanRTInfo);
+
+		rtinfo->plan_name = root->plan_name;
+		rtinfo->rtoffset = list_length(root->glob->finalrtable);
+
+		/* When recursing = true, it's an unplanned or dummy subquery. */
+		rtinfo->dummy = recursing;
+
+		root->glob->subrtinfos = lappend(root->glob->subrtinfos, rtinfo);
+	}
+
 	/*
 	 * Add the query's own RTEs to the flattened rangetable.
 	 *
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 30d889b54c5..a3a800869df 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -135,6 +135,9 @@ typedef struct PlannerGlobal
 	/* "flat" list of RTEPermissionInfos */
 	List	   *finalrteperminfos;
 
+	/* list of SubPlanRTInfo nodes */
+	List	   *subrtinfos;
+
 	/* "flat" list of PlanRowMarks */
 	List	   *finalrowmarks;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index c4393a94321..1526dd2ec6b 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -131,6 +131,9 @@ typedef struct PlannedStmt
 	 */
 	List	   *subplans;
 
+	/* a list of SubPlanRTInfo objects */
+	List	   *subrtinfos;
+
 	/* indices of subplans that require REWIND */
 	Bitmapset  *rewindPlanIDs;
 
@@ -1821,4 +1824,18 @@ typedef enum MonotonicFunction
 	MONOTONICFUNC_BOTH = MONOTONICFUNC_INCREASING | MONOTONICFUNC_DECREASING,
 } MonotonicFunction;
 
+/*
+ * SubPlanRTInfo
+ *
+ * Information about which range table entries came from which subquery
+ * planning cycles.
+ */
+typedef struct SubPlanRTInfo
+{
+	NodeTag		type;
+	const char *plan_name;
+	Index		rtoffset;
+	bool		dummy;
+} SubPlanRTInfo;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 432509277c9..60aa0b0937b 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2889,6 +2889,7 @@ SubLink
 SubLinkType
 SubOpts
 SubPlan
+SubPlanRTInfo
 SubPlanState
 SubRelInfo
 SubRemoveRels
-- 
2.51.0



  [application/octet-stream] v3-0004-Temporary-hack-to-unbreak-partitionwise-join-cont.patch (15.2K, 5-v3-0004-Temporary-hack-to-unbreak-partitionwise-join-cont.patch)
  download | inline diff:
From 97e540b19d8ffe2329fb59436f1ce64a6d6e58f1 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Wed, 29 Oct 2025 15:17:46 -0400
Subject: [PATCH v3 4/6] Temporary hack to unbreak partitionwise join control.

Resetting the pathlist and partial pathlist to NIL when the
topmost scan/join rel is a partitioned joinrel is incorrect. The issue
was originally reported by Ashutosh Bapat here:

http://postgr.es/m/CAExHW5toze58+jL-454J3ty11sqJyU13Sz5rJPQZDmASwZgWiA@mail.gmail.com

I failed to understand Ashutosh's explanation until I hit the problem
myself, so here's my attempt to re-explain what he had said, just in
case you find my explanation any clearer:

http://postgr.es/m/CA%2BTgmoZvBD%2B5vyQruXBVXW74FMgWxE%3DO4K4rCrCtEELWNj-MLA%40mail.gmail.com

As subsequent discussion on that thread indicates, it is unclear
exactly what the right fix for this problem is, and at least as of
this writing, it is even more unclear how to adjust the test cases
that break. What I've done here is just accept all the changes to the
regression test outputs, which is almost certainly the wrong idea,
especially since I've also added no comments.

This is just a temporary hack to make it possible to test this patch
set, because without this, PARTITIONWISE() advice can't be used to
suppress a partitionwise join, because all of the alternatives get
eliminated regardless of cost.
---
 src/backend/optimizer/plan/planner.c         |   4 +-
 src/test/regress/expected/partition_join.out | 172 ++++++++-----------
 src/test/regress/expected/subselect.out      |  41 ++---
 3 files changed, 91 insertions(+), 126 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index eb62794aecd..8b1ab847f39 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -7927,7 +7927,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	 * generate_useful_gather_paths to add path(s) to the main list, and
 	 * finally zap the partial pathlist.
 	 */
-	if (rel_is_partitioned)
+	if (rel_is_partitioned && IS_SIMPLE_REL(rel))
 		rel->pathlist = NIL;
 
 	/*
@@ -7953,7 +7953,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	}
 
 	/* Finish dropping old paths for a partitioned rel, per comment above */
-	if (rel_is_partitioned)
+	if (rel_is_partitioned && IS_SIMPLE_REL(rel))
 		rel->partial_pathlist = NIL;
 
 	/* Extract SRF-free scan/join target. */
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index 713828be335..3e34f05ba62 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -65,31 +65,24 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
 -- inner join with partially-redundant join clauses
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
-                          QUERY PLAN                           
----------------------------------------------------------------
- Sort
-   Sort Key: t1.a
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Merge Join
+   Merge Cond: (t1.a = t2.a)
    ->  Append
-         ->  Merge Join
-               Merge Cond: (t1_1.a = t2_1.a)
-               ->  Index Scan using iprt1_p1_a on prt1_p1 t1_1
-               ->  Sort
-                     Sort Key: t2_1.b
-                     ->  Seq Scan on prt2_p1 t2_1
-                           Filter: (a = b)
-         ->  Hash Join
-               Hash Cond: (t1_2.a = t2_2.a)
-               ->  Seq Scan on prt1_p2 t1_2
-               ->  Hash
-                     ->  Seq Scan on prt2_p2 t2_2
-                           Filter: (a = b)
-         ->  Hash Join
-               Hash Cond: (t1_3.a = t2_3.a)
-               ->  Seq Scan on prt1_p3 t1_3
-               ->  Hash
-                     ->  Seq Scan on prt2_p3 t2_3
-                           Filter: (a = b)
-(22 rows)
+         ->  Index Scan using iprt1_p1_a on prt1_p1 t1_1
+         ->  Index Scan using iprt1_p2_a on prt1_p2 t1_2
+         ->  Index Scan using iprt1_p3_a on prt1_p3 t1_3
+   ->  Sort
+         Sort Key: t2.b
+         ->  Append
+               ->  Seq Scan on prt2_p1 t2_1
+                     Filter: (a = b)
+               ->  Seq Scan on prt2_p2 t2_2
+                     Filter: (a = b)
+               ->  Seq Scan on prt2_p3 t2_3
+                     Filter: (a = b)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
  a  |  c   | b  |  c   
@@ -1249,56 +1242,50 @@ SET enable_hashjoin TO off;
 SET enable_nestloop TO off;
 EXPLAIN (COSTS OFF)
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
-                            QUERY PLAN                            
-------------------------------------------------------------------
- Merge Append
-   Sort Key: t1.a
-   ->  Merge Semi Join
-         Merge Cond: (t1_3.a = t1_6.b)
-         ->  Sort
-               Sort Key: t1_3.a
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Merge Join
+   Merge Cond: (t1.a = t1_1.b)
+   ->  Sort
+         Sort Key: t1.a
+         ->  Append
                ->  Seq Scan on prt1_p1 t1_3
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_6.b = (((t1_9.a + t1_9.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_6.b
-                     ->  Seq Scan on prt2_p1 t1_6
-               ->  Sort
-                     Sort Key: (((t1_9.a + t1_9.b) / 2))
-                     ->  Seq Scan on prt1_e_p1 t1_9
-                           Filter: (c = 0)
-   ->  Merge Semi Join
-         Merge Cond: (t1_4.a = t1_7.b)
-         ->  Sort
-               Sort Key: t1_4.a
                ->  Seq Scan on prt1_p2 t1_4
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_7.b = (((t1_10.a + t1_10.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_7.b
-                     ->  Seq Scan on prt2_p2 t1_7
-               ->  Sort
-                     Sort Key: (((t1_10.a + t1_10.b) / 2))
-                     ->  Seq Scan on prt1_e_p2 t1_10
-                           Filter: (c = 0)
-   ->  Merge Semi Join
-         Merge Cond: (t1_5.a = t1_8.b)
-         ->  Sort
-               Sort Key: t1_5.a
                ->  Seq Scan on prt1_p3 t1_5
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_8.b = (((t1_11.a + t1_11.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_8.b
-                     ->  Seq Scan on prt2_p3 t1_8
-               ->  Sort
-                     Sort Key: (((t1_11.a + t1_11.b) / 2))
-                     ->  Seq Scan on prt1_e_p3 t1_11
-                           Filter: (c = 0)
-(47 rows)
+   ->  Unique
+         ->  Merge Append
+               Sort Key: t1_1.b
+               ->  Merge Semi Join
+                     Merge Cond: (t1_6.b = (((t1_9.a + t1_9.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_6.b
+                           ->  Seq Scan on prt2_p1 t1_6
+                     ->  Sort
+                           Sort Key: (((t1_9.a + t1_9.b) / 2))
+                           ->  Seq Scan on prt1_e_p1 t1_9
+                                 Filter: (c = 0)
+               ->  Merge Semi Join
+                     Merge Cond: (t1_7.b = (((t1_10.a + t1_10.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_7.b
+                           ->  Seq Scan on prt2_p2 t1_7
+                     ->  Sort
+                           Sort Key: (((t1_10.a + t1_10.b) / 2))
+                           ->  Seq Scan on prt1_e_p2 t1_10
+                                 Filter: (c = 0)
+               ->  Merge Semi Join
+                     Merge Cond: (t1_8.b = (((t1_11.a + t1_11.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_8.b
+                           ->  Seq Scan on prt2_p3 t1_8
+                     ->  Sort
+                           Sort Key: (((t1_11.a + t1_11.b) / 2))
+                           ->  Seq Scan on prt1_e_p3 t1_11
+                                 Filter: (c = 0)
+(41 rows)
 
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -4923,32 +4910,27 @@ ANALYZE plt3_adv;
 -- '0001' of that partition
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM (plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.c = t2.c)) FULL JOIN plt3_adv t3 ON (t1.c = t3.c) WHERE coalesce(t1.a, 0) % 5 != 3 AND coalesce(t1.a, 0) % 5 != 4 ORDER BY t1.c, t1.a, t2.a, t3.a;
-                                          QUERY PLAN                                           
------------------------------------------------------------------------------------------------
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
  Sort
    Sort Key: t1.c, t1.a, t2.a, t3.a
-   ->  Append
-         ->  Hash Full Join
-               Hash Cond: (t1_1.c = t3_1.c)
-               Filter: (((COALESCE(t1_1.a, 0) % 5) <> 3) AND ((COALESCE(t1_1.a, 0) % 5) <> 4))
-               ->  Hash Left Join
-                     Hash Cond: (t1_1.c = t2_1.c)
+   ->  Hash Full Join
+         Hash Cond: (t1.c = t3.c)
+         Filter: (((COALESCE(t1.a, 0) % 5) <> 3) AND ((COALESCE(t1.a, 0) % 5) <> 4))
+         ->  Hash Left Join
+               Hash Cond: (t1.c = t2.c)
+               ->  Append
                      ->  Seq Scan on plt1_adv_p1 t1_1
-                     ->  Hash
-                           ->  Seq Scan on plt2_adv_p1 t2_1
-               ->  Hash
-                     ->  Seq Scan on plt3_adv_p1 t3_1
-         ->  Hash Full Join
-               Hash Cond: (t1_2.c = t3_2.c)
-               Filter: (((COALESCE(t1_2.a, 0) % 5) <> 3) AND ((COALESCE(t1_2.a, 0) % 5) <> 4))
-               ->  Hash Left Join
-                     Hash Cond: (t1_2.c = t2_2.c)
                      ->  Seq Scan on plt1_adv_p2 t1_2
-                     ->  Hash
-                           ->  Seq Scan on plt2_adv_p2 t2_2
                ->  Hash
+                     ->  Append
+                           ->  Seq Scan on plt2_adv_p1 t2_1
+                           ->  Seq Scan on plt2_adv_p2 t2_2
+         ->  Hash
+               ->  Append
+                     ->  Seq Scan on plt3_adv_p1 t3_1
                      ->  Seq Scan on plt3_adv_p2 t3_2
-(23 rows)
+(18 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM (plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.c = t2.c)) FULL JOIN plt3_adv t3 ON (t1.c = t3.c) WHERE coalesce(t1.a, 0) % 5 != 3 AND coalesce(t1.a, 0) % 5 != 4 ORDER BY t1.c, t1.a, t2.a, t3.a;
  a  |  c   | a  |  c   | a  |  c   
@@ -5240,17 +5222,15 @@ SELECT x.id, y.id FROM fract_t x LEFT JOIN fract_t y USING (id) ORDER BY x.id AS
                               QUERY PLAN                               
 -----------------------------------------------------------------------
  Limit
-   ->  Merge Append
-         Sort Key: x.id
-         ->  Merge Left Join
-               Merge Cond: (x_1.id = y_1.id)
+   ->  Merge Left Join
+         Merge Cond: (x.id = y.id)
+         ->  Append
                ->  Index Only Scan using fract_t0_pkey on fract_t0 x_1
-               ->  Index Only Scan using fract_t0_pkey on fract_t0 y_1
-         ->  Merge Left Join
-               Merge Cond: (x_2.id = y_2.id)
                ->  Index Only Scan using fract_t1_pkey on fract_t1 x_2
+         ->  Append
+               ->  Index Only Scan using fract_t0_pkey on fract_t0 y_1
                ->  Index Only Scan using fract_t1_pkey on fract_t1 y_2
-(11 rows)
+(9 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT x.id, y.id FROM fract_t x LEFT JOIN fract_t y USING (id) ORDER BY x.id DESC LIMIT 10;
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index cf6b32d1173..8549601e3bc 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -850,10 +850,11 @@ where (t1.a, t2.a) in (select a, a from unique_tbl_p t3)
 order by t1.a, t2.a;
                                            QUERY PLAN                                           
 ------------------------------------------------------------------------------------------------
- Merge Append
-   Sort Key: t1.a
-   ->  Nested Loop
-         Output: t1_1.a, t1_1.b, t2_1.a, t2_1.b
+ Merge Join
+   Output: t1.a, t1.b, t2.a, t2.b
+   Merge Cond: (t1.a = t2.a)
+   ->  Merge Append
+         Sort Key: t1.a
          ->  Nested Loop
                Output: t1_1.a, t1_1.b, t3_1.a
                ->  Unique
@@ -863,15 +864,6 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t1_1
                      Output: t1_1.a, t1_1.b
                      Index Cond: (t1_1.a = t3_1.a)
-         ->  Memoize
-               Output: t2_1.a, t2_1.b
-               Cache Key: t1_1.a
-               Cache Mode: logical
-               ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t2_1
-                     Output: t2_1.a, t2_1.b
-                     Index Cond: (t2_1.a = t1_1.a)
-   ->  Nested Loop
-         Output: t1_2.a, t1_2.b, t2_2.a, t2_2.b
          ->  Nested Loop
                Output: t1_2.a, t1_2.b, t3_2.a
                ->  Unique
@@ -881,15 +873,6 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t1_2
                      Output: t1_2.a, t1_2.b
                      Index Cond: (t1_2.a = t3_2.a)
-         ->  Memoize
-               Output: t2_2.a, t2_2.b
-               Cache Key: t1_2.a
-               Cache Mode: logical
-               ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t2_2
-                     Output: t2_2.a, t2_2.b
-                     Index Cond: (t2_2.a = t1_2.a)
-   ->  Nested Loop
-         Output: t1_3.a, t1_3.b, t2_3.a, t2_3.b
          ->  Nested Loop
                Output: t1_3.a, t1_3.b, t3_3.a
                ->  Unique
@@ -902,14 +885,16 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p3_a_idx on public.unique_tbl_p3 t1_3
                      Output: t1_3.a, t1_3.b
                      Index Cond: (t1_3.a = t3_3.a)
-         ->  Memoize
-               Output: t2_3.a, t2_3.b
-               Cache Key: t1_3.a
-               Cache Mode: logical
+   ->  Materialize
+         Output: t2.a, t2.b
+         ->  Append
+               ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t2_1
+                     Output: t2_1.a, t2_1.b
+               ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t2_2
+                     Output: t2_2.a, t2_2.b
                ->  Index Scan using unique_tbl_p3_a_idx on public.unique_tbl_p3 t2_3
                      Output: t2_3.a, t2_3.b
-                     Index Cond: (t2_3.a = t1_3.a)
-(59 rows)
+(44 rows)
 
 reset enable_partitionwise_join;
 drop table unique_tbl_p;
-- 
2.51.0



  [application/octet-stream] v3-0003-Store-information-about-Append-node-consolidation.patch (27.0K, 6-v3-0003-Store-information-about-Append-node-consolidation.patch)
  download | inline diff:
From 0bc729a60c1e4220564ebef714356240695d0486 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:07 -0400
Subject: [PATCH v3 3/6] Store information about Append node consolidation in
 the final plan.

An extension (or core code) might want to reconstruct the planner's
decisions about whether and where to perform partitionwise joins from
the final plan. To do so, it must be possible to find all of the RTIs
of partitioned tables appearing in the plan. But when an AppendPath
or MergeAppendPath pulls up child paths from a subordinate AppendPath
or MergeAppendPath, the RTIs of the subordinate path do not appear
in the final plan, making this kind of reconstruction impossible.

To avoid this, propagate the RTI sets that would have been present
in the 'apprelids' field of the subordinate Append or MergeAppend
nodes that would have been created into the surviving Append or
MergeAppend node, using a new 'child_append_relid_sets' field for
that purpose. The value of this field is a list of Bitmapsets,
because each relation whose append-list was pulled up had its own
set of RTIs: just one, if it was a partitionwise scan, or more than
one, if it was a partitionwise join. Since our goal is to see where
partitionwise joins were done, it is essential to avoid losing the
information about how the RTIs were grouped in the pulled-up
relations.

This commit also updates pg_overexplain so that EXPLAIN (RANGE_TABLE)
will display the saved RTI sets.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 56 +++++++++++
 src/backend/optimizer/path/allpaths.c         | 98 +++++++++++++++----
 src/backend/optimizer/path/joinrels.c         |  2 +-
 src/backend/optimizer/plan/createplan.c       |  2 +
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/prep/prepunion.c        | 11 ++-
 src/backend/optimizer/util/pathnode.c         |  5 +
 src/include/nodes/pathnodes.h                 | 10 ++
 src/include/nodes/plannodes.h                 | 11 +++
 src/include/optimizer/pathnode.h              |  2 +
 11 files changed, 175 insertions(+), 27 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index ca9a23ea61f..a377fb2571d 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -104,6 +104,7 @@ $$);
                Parallel Safe: true
                Plan Node ID: 2
                Append RTIs: 1
+               Child Append RTIs: none
                ->  Seq Scan on brassica vegetables_1
                      Disabled Nodes: 0
                      Parallel Safe: true
@@ -142,7 +143,7 @@ $$);
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 3 4
-(53 rows)
+(54 rows)
 
 -- Test a different output format.
 SELECT explain_filter($$
@@ -197,6 +198,7 @@ $$);
                <extParam>none</extParam>                            +
                <allParam>none</allParam>                            +
                <Append-RTIs>1</Append-RTIs>                         +
+               <Child-Append-RTIs>none</Child-Append-RTIs>          +
                <Subplans-Removed>0</Subplans-Removed>               +
                <Plans>                                              +
                  <Plan>                                             +
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index fa907fa472e..6538ffcafb0 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -54,6 +54,8 @@ static void overexplain_alias(const char *qlabel, Alias *alias,
 							  ExplainState *es);
 static void overexplain_bitmapset(const char *qlabel, Bitmapset *bms,
 								  ExplainState *es);
+static void overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+									   ExplainState *es);
 static void overexplain_intlist(const char *qlabel, List *list,
 								ExplainState *es);
 
@@ -232,11 +234,17 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 				overexplain_bitmapset("Append RTIs",
 									  ((Append *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((Append *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_MergeAppend:
 				overexplain_bitmapset("Append RTIs",
 									  ((MergeAppend *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((MergeAppend *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_Result:
 
@@ -815,6 +823,54 @@ overexplain_bitmapset(const char *qlabel, Bitmapset *bms, ExplainState *es)
 	pfree(buf.data);
 }
 
+/*
+ * Emit a text property describing the contents of a list of bitmapsets.
+ * If a bitmapset contains exactly 1 member, we just print an integer;
+ * otherwise, we surround the list of members by parentheses.
+ *
+ * If there are no bitmapsets in the list, we print the word "none".
+ */
+static void
+overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+						   ExplainState *es)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+
+	foreach_node(Bitmapset, bms, bms_list)
+	{
+		if (bms_membership(bms) == BMS_SINGLETON)
+			appendStringInfo(&buf, " %d", bms_singleton_member(bms));
+		else
+		{
+			int			x = -1;
+			bool		first = true;
+
+			appendStringInfoString(&buf, " (");
+			while ((x = bms_next_member(bms, x)) >= 0)
+			{
+				if (first)
+					first = false;
+				else
+					appendStringInfoChar(&buf, ' ');
+				appendStringInfo(&buf, "%d", x);
+			}
+			appendStringInfoChar(&buf, ')');
+		}
+	}
+
+	if (buf.len == 0)
+	{
+		ExplainPropertyText(qlabel, "none", es);
+		return;
+	}
+
+	Assert(buf.data[0] == ' ');
+	ExplainPropertyText(qlabel, buf.data + 1, es);
+	pfree(buf.data);
+}
+
 /*
  * Emit a text property describing the contents of a list of integers, OIDs,
  * or XIDs -- either a space-separated list of integer members, or the word
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 4c43fd0b19b..928b8d84ad8 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -128,8 +128,10 @@ static Path *get_cheapest_parameterized_child_path(PlannerInfo *root,
 												   Relids required_outer);
 static void accumulate_append_subpath(Path *path,
 									  List **subpaths,
-									  List **special_subpaths);
-static Path *get_singleton_append_subpath(Path *path);
+									  List **special_subpaths,
+									  List **child_append_relid_sets);
+static Path *get_singleton_append_subpath(Path *path,
+										  List **child_append_relid_sets);
 static void set_dummy_rel_pathlist(RelOptInfo *rel);
 static void set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 								  Index rti, RangeTblEntry *rte);
@@ -1406,11 +1408,15 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 {
 	List	   *subpaths = NIL;
 	bool		subpaths_valid = true;
+	List	   *subpath_cars = NIL;
 	List	   *startup_subpaths = NIL;
 	bool		startup_subpaths_valid = true;
+	List	   *startup_subpath_cars = NIL;
 	List	   *partial_subpaths = NIL;
+	List	   *partial_subpath_cars = NIL;
 	List	   *pa_partial_subpaths = NIL;
 	List	   *pa_nonpartial_subpaths = NIL;
+	List	   *pa_subpath_cars = NIL;
 	bool		partial_subpaths_valid = true;
 	bool		pa_subpaths_valid;
 	List	   *all_child_pathkeys = NIL;
@@ -1443,7 +1449,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		if (childrel->pathlist != NIL &&
 			childrel->cheapest_total_path->param_info == NULL)
 			accumulate_append_subpath(childrel->cheapest_total_path,
-									  &subpaths, NULL);
+									  &subpaths, NULL, &subpath_cars);
 		else
 			subpaths_valid = false;
 
@@ -1472,7 +1478,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 			Assert(cheapest_path->param_info == NULL);
 			accumulate_append_subpath(cheapest_path,
 									  &startup_subpaths,
-									  NULL);
+									  NULL,
+									  &startup_subpath_cars);
 		}
 		else
 			startup_subpaths_valid = false;
@@ -1483,7 +1490,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		{
 			cheapest_partial_path = linitial(childrel->partial_pathlist);
 			accumulate_append_subpath(cheapest_partial_path,
-									  &partial_subpaths, NULL);
+									  &partial_subpaths, NULL,
+									  &partial_subpath_cars);
 		}
 		else
 			partial_subpaths_valid = false;
@@ -1512,7 +1520,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				Assert(cheapest_partial_path != NULL);
 				accumulate_append_subpath(cheapest_partial_path,
 										  &pa_partial_subpaths,
-										  &pa_nonpartial_subpaths);
+										  &pa_nonpartial_subpaths,
+										  &pa_subpath_cars);
 			}
 			else
 			{
@@ -1531,7 +1540,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				 */
 				accumulate_append_subpath(nppath,
 										  &pa_nonpartial_subpaths,
-										  NULL);
+										  NULL,
+										  &pa_subpath_cars);
 			}
 		}
 
@@ -1606,14 +1616,16 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 	 * if we have zero or one live subpath due to constraint exclusion.)
 	 */
 	if (subpaths_valid)
-		add_path(rel, (Path *) create_append_path(root, rel, subpaths, NIL,
+		add_path(rel, (Path *) create_append_path(root, rel, subpaths,
+												  NIL, subpath_cars,
 												  NIL, NULL, 0, false,
 												  -1));
 
 	/* build an AppendPath for the cheap startup paths, if valid */
 	if (startup_subpaths_valid)
 		add_path(rel, (Path *) create_append_path(root, rel, startup_subpaths,
-												  NIL, NIL, NULL, 0, false, -1));
+												  NIL, startup_subpath_cars,
+												  NIL, NULL, 0, false, -1));
 
 	/*
 	 * Consider an append of unordered, unparameterized partial paths.  Make
@@ -1654,6 +1666,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Generate a partial append path. */
 		appendpath = create_append_path(root, rel, NIL, partial_subpaths,
+										partial_subpath_cars,
 										NIL, NULL, parallel_workers,
 										enable_parallel_append,
 										-1);
@@ -1704,6 +1717,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		appendpath = create_append_path(root, rel, pa_nonpartial_subpaths,
 										pa_partial_subpaths,
+										pa_subpath_cars,
 										NIL, NULL, parallel_workers, true,
 										partial_rows);
 		add_partial_path(rel, (Path *) appendpath);
@@ -1737,6 +1751,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Select the child paths for an Append with this parameterization */
 		subpaths = NIL;
+		subpath_cars = NIL;
 		subpaths_valid = true;
 		foreach(lcr, live_childrels)
 		{
@@ -1759,12 +1774,13 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				subpaths_valid = false;
 				break;
 			}
-			accumulate_append_subpath(subpath, &subpaths, NULL);
+			accumulate_append_subpath(subpath, &subpaths, NULL,
+									  &subpath_cars);
 		}
 
 		if (subpaths_valid)
 			add_path(rel, (Path *)
-					 create_append_path(root, rel, subpaths, NIL,
+					 create_append_path(root, rel, subpaths, NIL, subpath_cars,
 										NIL, required_outer, 0, false,
 										-1));
 	}
@@ -1791,6 +1807,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				continue;
 
 			appendpath = create_append_path(root, rel, NIL, list_make1(path),
+											list_make1(rel->relids),
 											NIL, NULL,
 											path->parallel_workers, true,
 											partial_rows);
@@ -1874,8 +1891,11 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 	{
 		List	   *pathkeys = (List *) lfirst(lcp);
 		List	   *startup_subpaths = NIL;
+		List	   *startup_subpath_cars = NIL;
 		List	   *total_subpaths = NIL;
+		List	   *total_subpath_cars = NIL;
 		List	   *fractional_subpaths = NIL;
+		List	   *fractional_subpath_cars = NIL;
 		bool		startup_neq_total = false;
 		bool		fraction_neq_total = false;
 		bool		match_partition_order;
@@ -2038,16 +2058,23 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * just a single subpath (and hence aren't doing anything
 				 * useful).
 				 */
-				cheapest_startup = get_singleton_append_subpath(cheapest_startup);
-				cheapest_total = get_singleton_append_subpath(cheapest_total);
+				cheapest_startup =
+					get_singleton_append_subpath(cheapest_startup,
+												 &startup_subpath_cars);
+				cheapest_total =
+					get_singleton_append_subpath(cheapest_total,
+												 &total_subpath_cars);
 
 				startup_subpaths = lappend(startup_subpaths, cheapest_startup);
 				total_subpaths = lappend(total_subpaths, cheapest_total);
 
 				if (cheapest_fractional)
 				{
-					cheapest_fractional = get_singleton_append_subpath(cheapest_fractional);
-					fractional_subpaths = lappend(fractional_subpaths, cheapest_fractional);
+					cheapest_fractional =
+						get_singleton_append_subpath(cheapest_fractional,
+													 &fractional_subpath_cars);
+					fractional_subpaths =
+						lappend(fractional_subpaths, cheapest_fractional);
 				}
 			}
 			else
@@ -2057,13 +2084,16 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * child paths for the MergeAppend.
 				 */
 				accumulate_append_subpath(cheapest_startup,
-										  &startup_subpaths, NULL);
+										  &startup_subpaths, NULL,
+										  &startup_subpath_cars);
 				accumulate_append_subpath(cheapest_total,
-										  &total_subpaths, NULL);
+										  &total_subpaths, NULL,
+										  &total_subpath_cars);
 
 				if (cheapest_fractional)
 					accumulate_append_subpath(cheapest_fractional,
-											  &fractional_subpaths, NULL);
+											  &fractional_subpaths, NULL,
+											  &fractional_subpath_cars);
 			}
 		}
 
@@ -2075,6 +2105,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 													  rel,
 													  startup_subpaths,
 													  NIL,
+													  startup_subpath_cars,
 													  pathkeys,
 													  NULL,
 													  0,
@@ -2085,6 +2116,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  total_subpaths,
 														  NIL,
+														  total_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2096,6 +2128,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  fractional_subpaths,
 														  NIL,
+														  fractional_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2108,12 +2141,14 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 			add_path(rel, (Path *) create_merge_append_path(root,
 															rel,
 															startup_subpaths,
+															startup_subpath_cars,
 															pathkeys,
 															NULL));
 			if (startup_neq_total)
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																total_subpaths,
+																total_subpath_cars,
 																pathkeys,
 																NULL));
 
@@ -2121,6 +2156,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																fractional_subpaths,
+																fractional_subpath_cars,
 																pathkeys,
 																NULL));
 		}
@@ -2223,7 +2259,8 @@ get_cheapest_parameterized_child_path(PlannerInfo *root, RelOptInfo *rel,
  * paths).
  */
 static void
-accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
+accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
+						  List **child_append_relid_sets)
 {
 	if (IsA(path, AppendPath))
 	{
@@ -2232,6 +2269,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		if (!apath->path.parallel_aware || apath->first_partial_path == 0)
 		{
 			*subpaths = list_concat(*subpaths, apath->subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 		else if (special_subpaths != NULL)
@@ -2246,6 +2285,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 												  apath->first_partial_path);
 			*special_subpaths = list_concat(*special_subpaths,
 											new_special_subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 	}
@@ -2254,6 +2295,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		*subpaths = list_concat(*subpaths, mpath->subpaths);
+		*child_append_relid_sets =
+			lappend(*child_append_relid_sets, path->parent->relids);
 		return;
 	}
 
@@ -2265,10 +2308,15 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
  *		Returns the single subpath of an Append/MergeAppend, or just
  *		return 'path' if it's not a single sub-path Append/MergeAppend.
  *
+ * As a side effect, whenever we return a single subpath rather than the
+ * original path, add the relid set for the original path to
+ * child_append_relid_sets, so that those relids don't entirely disappear
+ * from the final plan.
+ *
  * Note: 'path' must not be a parallel-aware path.
  */
 static Path *
-get_singleton_append_subpath(Path *path)
+get_singleton_append_subpath(Path *path, List **child_append_relid_sets)
 {
 	Assert(!path->parallel_aware);
 
@@ -2277,14 +2325,22 @@ get_singleton_append_subpath(Path *path)
 		AppendPath *apath = (AppendPath *) path;
 
 		if (list_length(apath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(apath->subpaths);
+		}
 	}
 	else if (IsA(path, MergeAppendPath))
 	{
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		if (list_length(mpath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(mpath->subpaths);
+		}
 	}
 
 	return path;
@@ -2313,7 +2369,7 @@ set_dummy_rel_pathlist(RelOptInfo *rel)
 	rel->partial_pathlist = NIL;
 
 	/* Set up the dummy path */
-	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
+	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL, NIL,
 											  NIL, rel->lateral_relids,
 											  0, false, -1));
 
diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 5d1fc3899da..c1ed0d3870f 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -1530,7 +1530,7 @@ mark_dummy_rel(RelOptInfo *rel)
 
 	/* Set up the dummy path */
 	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
-											  NIL, rel->lateral_relids,
+											  NIL, NIL, rel->lateral_relids,
 											  0, false, -1));
 
 	/* Set or update cheapest_total_path and related fields */
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 8af091ba647..88b4c5901b0 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -1265,6 +1265,7 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags)
 	plan->plan.lefttree = NULL;
 	plan->plan.righttree = NULL;
 	plan->apprelids = rel->relids;
+	plan->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	if (pathkeys != NIL)
 	{
@@ -1477,6 +1478,7 @@ create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path,
 	plan->lefttree = NULL;
 	plan->righttree = NULL;
 	node->apprelids = rel->relids;
+	node->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	/*
 	 * Compute sort column info, and adjust MergeAppend's tlist as needed.
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 9d5262651e7..eb62794aecd 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4027,6 +4027,7 @@ create_degenerate_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
 							   paths,
 							   NIL,
 							   NIL,
+							   NIL,
 							   NULL,
 							   0,
 							   false,
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index f528f096a56..ca2258e44d1 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -843,7 +843,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 	 * union child.
 	 */
 	apath = (Path *) create_append_path(root, result_rel, cheapest_pathlist,
-										NIL, NIL, NULL, 0, false, -1);
+										NIL, NIL, NIL, NULL, 0, false, -1);
 
 	/*
 	 * Estimate number of groups.  For now we just assume the output is unique
@@ -889,7 +889,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 
 		papath = (Path *)
 			create_append_path(root, result_rel, NIL, partial_pathlist,
-							   NIL, NULL, parallel_workers,
+							   NIL, NIL, NULL, parallel_workers,
 							   enable_parallel_append, -1);
 		gpath = (Path *)
 			create_gather_path(root, result_rel, papath,
@@ -1018,6 +1018,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 			path = (Path *) create_merge_append_path(root,
 													 result_rel,
 													 ordered_pathlist,
+													 NIL,
 													 union_pathkeys,
 													 NULL);
 
@@ -1224,8 +1225,10 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root,
 				 * between the set op targetlist and the targetlist of the
 				 * left input.  The Append will be removed in setrefs.c.
 				 */
-				apath = (Path *) create_append_path(root, result_rel, list_make1(lpath),
-													NIL, NIL, NULL, 0, false, -1);
+				apath = (Path *) create_append_path(root, result_rel,
+													list_make1(lpath),
+													NIL, NIL, NIL, NULL, 0,
+													false, -1);
 
 				add_path(result_rel, apath);
 
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index e4fd6950fad..c0a9811b130 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1300,6 +1300,7 @@ AppendPath *
 create_append_path(PlannerInfo *root,
 				   RelOptInfo *rel,
 				   List *subpaths, List *partial_subpaths,
+				   List *child_append_relid_sets,
 				   List *pathkeys, Relids required_outer,
 				   int parallel_workers, bool parallel_aware,
 				   double rows)
@@ -1309,6 +1310,7 @@ create_append_path(PlannerInfo *root,
 
 	Assert(!parallel_aware || parallel_workers > 0);
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_Append;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -1471,6 +1473,7 @@ MergeAppendPath *
 create_merge_append_path(PlannerInfo *root,
 						 RelOptInfo *rel,
 						 List *subpaths,
+						 List *child_append_relid_sets,
 						 List *pathkeys,
 						 Relids required_outer)
 {
@@ -1486,6 +1489,7 @@ create_merge_append_path(PlannerInfo *root,
 	 */
 	Assert(bms_is_empty(rel->lateral_relids) && bms_is_empty(required_outer));
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_MergeAppend;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -3950,6 +3954,7 @@ reparameterize_path(PlannerInfo *root, Path *path,
 				}
 				return (Path *)
 					create_append_path(root, rel, childpaths, partialpaths,
+									   apath->child_append_relid_sets,
 									   apath->path.pathkeys, required_outer,
 									   apath->path.parallel_workers,
 									   apath->path.parallel_aware,
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index cf3a16b8b0e..75a70489e5a 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2171,6 +2171,12 @@ typedef struct CustomPath
  * For partial Append, 'subpaths' contains non-partial subpaths followed by
  * partial subpaths.
  *
+ * Whenever accumulate_append_subpath() allows us to consolidate multiple
+ * levels of Append paths are consolidated down to one, we store the RTI
+ * sets for the omitted paths in child_append_relid_sets. This is not necessary
+ * for planning or execution; we do it for the benefit of code that wants
+ * to inspect the final plan and understand how it came to be.
+ *
  * Note: it is possible for "subpaths" to contain only one, or even no,
  * elements.  These cases are optimized during create_append_plan.
  * In particular, an AppendPath with no subpaths is a "dummy" path that
@@ -2186,6 +2192,7 @@ typedef struct AppendPath
 	/* Index of first partial path in subpaths; list_length(subpaths) if none */
 	int			first_partial_path;
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } AppendPath;
 
 #define IS_DUMMY_APPEND(p) \
@@ -2202,12 +2209,15 @@ extern bool is_dummy_rel(RelOptInfo *rel);
 /*
  * MergeAppendPath represents a MergeAppend plan, ie, the merging of sorted
  * results from several member plans to produce similarly-sorted output.
+ *
+ * child_append_relid_sets has the same meaning here as for AppendPath.
  */
 typedef struct MergeAppendPath
 {
 	Path		path;
 	List	   *subpaths;		/* list of component Paths */
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } MergeAppendPath;
 
 /*
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 5d0520d5e58..045b7ee84a7 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -394,9 +394,16 @@ struct PartitionPruneInfo;		/* forward reference to struct below */
 typedef struct Append
 {
 	Plan		plan;
+
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
+
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *appendplans;
+
 	/* # of asynchronous plans */
 	int			nasyncplans;
 
@@ -426,6 +433,10 @@ typedef struct MergeAppend
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
 
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *mergeplans;
 
 	/* these fields are just like the sort-key info in struct Sort: */
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 955e9056858..4437248cb67 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -70,12 +70,14 @@ extern TidRangePath *create_tidrangescan_path(PlannerInfo *root,
 											  Relids required_outer);
 extern AppendPath *create_append_path(PlannerInfo *root, RelOptInfo *rel,
 									  List *subpaths, List *partial_subpaths,
+									  List *child_append_relid_sets,
 									  List *pathkeys, Relids required_outer,
 									  int parallel_workers, bool parallel_aware,
 									  double rows);
 extern MergeAppendPath *create_merge_append_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 List *subpaths,
+												 List *child_append_relid_sets,
 												 List *pathkeys,
 												 Relids required_outer);
 extern GroupResultPath *create_group_result_path(PlannerInfo *root,
-- 
2.51.0



  [application/octet-stream] v3-0006-WIP-Add-pg_plan_advice-contrib-module.patch (374.4K, 7-v3-0006-WIP-Add-pg_plan_advice-contrib-module.patch)
  download | inline diff:
From db2b53bf41f405b83ac4e3c621703a32530850a4 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 4 Nov 2025 14:45:31 -0500
Subject: [PATCH v3 6/6] WIP: Add pg_plan_advice contrib module.

Provide a facility that (1) can be used to stabilize certain plan choices
so that the planner cannot reverse course without authorization and
(2) can be used by knowledgeable users to insist on plan choices contrary
to what the planner believes best. In both cases, terrible outcomes are
possible: users should think twice and perhaps three times before
constraining the planner's ability to do as it thinks best; nevertheless,
there are problems that are much more easily solved with these facilities
than without them.

We take the approach of analyzing a finished plan to produce textual
output, which we call "plan advice", that describes key decisions made
during plan; if that plan advice is provided during future planning
cycles, it will force those key decisions to be made in the same way.
Not all planner decisions can be controlled using advice; for example,
decisions about how to perform aggregation are currently out of scope,
as is choice of sort order. Plan advice can also be edited by the user,
or even written from scratch in simple cases, making it possible to
generate outcomes that the planner would not have produced. Partial
advice can be provided to control some planner outcomes but not others.

Currently, plan advice is focused only on specific outcomes, such as
the choice to use a sequential scan for a particular relation, and not
on estimates that might contribute to those outcomes, such as a
possibly-incorrect selectivity estimate. While it would be useful to
users to be able to provide plan advice that affects selectivity
estimates or other aspects of costing, that is out of scope for this
commit.

For more details, see contrib/pg_plan_advice/README.

NOTE: This code is just a proof of concept. A bunch of things don't
work and a lot of the code needs cleanup. It has no SGML documentation
and not enough test cases, and some of the existing test cases don't
do as we would hope. Known problems are called out by XXX.
---
 contrib/Makefile                              |    1 +
 contrib/meson.build                           |    1 +
 contrib/pg_plan_advice/.gitignore             |    3 +
 contrib/pg_plan_advice/Makefile               |   46 +
 contrib/pg_plan_advice/README                 |  275 +++
 contrib/pg_plan_advice/expected/gather.out    |  320 ++++
 .../pg_plan_advice/expected/join_order.out    |  292 +++
 .../pg_plan_advice/expected/join_strategy.out |  297 +++
 .../expected/local_collector.out              |   64 +
 .../pg_plan_advice/expected/partitionwise.out |  243 +++
 contrib/pg_plan_advice/expected/scan.out      |  757 ++++++++
 contrib/pg_plan_advice/expected/syntax.out    |   59 +
 contrib/pg_plan_advice/meson.build            |   70 +
 .../pg_plan_advice/pg_plan_advice--1.0.sql    |   42 +
 contrib/pg_plan_advice/pg_plan_advice.c       |  454 +++++
 contrib/pg_plan_advice/pg_plan_advice.control |    5 +
 contrib/pg_plan_advice/pg_plan_advice.h       |   37 +
 contrib/pg_plan_advice/pgpa_ast.c             |  392 ++++
 contrib/pg_plan_advice/pgpa_ast.h             |  204 ++
 contrib/pg_plan_advice/pgpa_collector.c       |  637 ++++++
 contrib/pg_plan_advice/pgpa_collector.h       |   18 +
 contrib/pg_plan_advice/pgpa_identifier.c      |  476 +++++
 contrib/pg_plan_advice/pgpa_identifier.h      |   52 +
 contrib/pg_plan_advice/pgpa_join.c            |  615 ++++++
 contrib/pg_plan_advice/pgpa_join.h            |  105 +
 contrib/pg_plan_advice/pgpa_output.c          |  628 ++++++
 contrib/pg_plan_advice/pgpa_output.h          |   22 +
 contrib/pg_plan_advice/pgpa_parser.y          |  337 ++++
 contrib/pg_plan_advice/pgpa_planner.c         | 1706 +++++++++++++++++
 contrib/pg_plan_advice/pgpa_planner.h         |   17 +
 contrib/pg_plan_advice/pgpa_scan.c            |  278 +++
 contrib/pg_plan_advice/pgpa_scan.h            |   86 +
 contrib/pg_plan_advice/pgpa_scanner.l         |  299 +++
 contrib/pg_plan_advice/pgpa_trove.c           |  490 +++++
 contrib/pg_plan_advice/pgpa_trove.h           |  113 ++
 contrib/pg_plan_advice/pgpa_walker.c          |  862 +++++++++
 contrib/pg_plan_advice/pgpa_walker.h          |  121 ++
 contrib/pg_plan_advice/sql/gather.sql         |   76 +
 contrib/pg_plan_advice/sql/join_order.sql     |   96 +
 contrib/pg_plan_advice/sql/join_strategy.sql  |   76 +
 .../pg_plan_advice/sql/local_collector.sql    |   40 +
 contrib/pg_plan_advice/sql/partitionwise.sql  |   78 +
 contrib/pg_plan_advice/sql/scan.sql           |  195 ++
 contrib/pg_plan_advice/sql/syntax.sql         |   42 +
 contrib/pg_plan_advice/t/001_regress.pl       |  139 ++
 src/tools/pgindent/typedefs.list              |   37 +
 46 files changed, 11203 insertions(+)
 create mode 100644 contrib/pg_plan_advice/.gitignore
 create mode 100644 contrib/pg_plan_advice/Makefile
 create mode 100644 contrib/pg_plan_advice/README
 create mode 100644 contrib/pg_plan_advice/expected/gather.out
 create mode 100644 contrib/pg_plan_advice/expected/join_order.out
 create mode 100644 contrib/pg_plan_advice/expected/join_strategy.out
 create mode 100644 contrib/pg_plan_advice/expected/local_collector.out
 create mode 100644 contrib/pg_plan_advice/expected/partitionwise.out
 create mode 100644 contrib/pg_plan_advice/expected/scan.out
 create mode 100644 contrib/pg_plan_advice/expected/syntax.out
 create mode 100644 contrib/pg_plan_advice/meson.build
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice--1.0.sql
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.c
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.control
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.h
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.c
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.h
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.c
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.h
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.c
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.h
 create mode 100644 contrib/pg_plan_advice/pgpa_join.c
 create mode 100644 contrib/pg_plan_advice/pgpa_join.h
 create mode 100644 contrib/pg_plan_advice/pgpa_output.c
 create mode 100644 contrib/pg_plan_advice/pgpa_output.h
 create mode 100644 contrib/pg_plan_advice/pgpa_parser.y
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.c
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.c
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scanner.l
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.c
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.h
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.c
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.h
 create mode 100644 contrib/pg_plan_advice/sql/gather.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_order.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_strategy.sql
 create mode 100644 contrib/pg_plan_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_plan_advice/sql/partitionwise.sql
 create mode 100644 contrib/pg_plan_advice/sql/scan.sql
 create mode 100644 contrib/pg_plan_advice/sql/syntax.sql
 create mode 100644 contrib/pg_plan_advice/t/001_regress.pl

diff --git a/contrib/Makefile b/contrib/Makefile
index 2f0a88d3f77..dd04c20acd2 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -34,6 +34,7 @@ SUBDIRS = \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
+		pg_plan_advice \
 		pg_prewarm	\
 		pg_stat_statements \
 		pg_surgery	\
diff --git a/contrib/meson.build b/contrib/meson.build
index ed30ee7d639..cb718dbdac0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -48,6 +48,7 @@ subdir('pgcrypto')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
+subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
 subdir('pg_stat_statements')
diff --git a/contrib/pg_plan_advice/.gitignore b/contrib/pg_plan_advice/.gitignore
new file mode 100644
index 00000000000..19a14253019
--- /dev/null
+++ b/contrib/pg_plan_advice/.gitignore
@@ -0,0 +1,3 @@
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
new file mode 100644
index 00000000000..d7e06fc74ae
--- /dev/null
+++ b/contrib/pg_plan_advice/Makefile
@@ -0,0 +1,46 @@
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_plan_advice.o \
+	pgpa_ast.o \
+	pgpa_collector.o \
+	pgpa_identifier.o \
+	pgpa_join.o \
+	pgpa_output.o \
+	pgpa_parser.o \
+	pgpa_planner.o \
+	pgpa_scan.o \
+	pgpa_scanner.o \
+	pgpa_trove.o \
+	pgpa_walker.o
+
+EXTENSION = pg_plan_advice
+DATA = pg_plan_advice--1.0.sql
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise scan
+TAP_TESTS = 1
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+	touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
new file mode 100644
index 00000000000..4590cd03ce5
--- /dev/null
+++ b/contrib/pg_plan_advice/README
@@ -0,0 +1,275 @@
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planing
+cycle, each piece of advice should be guaranteed to apply to the exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should succesfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it is appears to be good advice or bad advice.  If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+   e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously.  Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables.  When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by curly brances, e.g. SOME_SORT_OF_SCAN(foo (bar baz)) would mean that the
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+Bitmap heap scans allow for a more complicated index specification. For
+example, BITMAP_HEAP_SCAN(foo &&(foo_a_idx ||(foo_b_idx foo_c_idx))) says
+that foo should be scanned using a BitmapHeapScan over a BitmapAnd between
+foo_a_idx and the result of a BitmapOr between foo_b_idx and foo_c_idx.
+
+XXX: Currently, BITMAP_HEAP_SCAN does not enforce the index specification,
+because the available hooks are insufficient to do so. It's possible that
+this should be changed to exclude the index specification altogether and
+simply insist that some sort of bitmap heap scan is used; alternatively,
+we need better hooks.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ORDER() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructured. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Strategy Advice
+====================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+strategy advice; no join strategy advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join strategy advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the planner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join strategy advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join strategy advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join strategy advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+XXX: Currently, SEMIJOIN_NON_UNIQUE() advice is emitted in some situations
+where the SEMIJOIN_UNIQUE() approach was determined to be non-viable; ideally,
+we should avoid that.
+
+XXX: Right semijoins haven't been properly thought through. The associated
+code probably just doesn't work.
+
+XXX: Semijoin uniqueness advice has no automated tests and need substantially
+more manual testing.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advise can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constraint the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the outer
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constraint won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
new file mode 100644
index 00000000000..d0224a2aee7
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -0,0 +1,320 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Sort Key: f.dim_id
+   ->  Gather
+         Workers Planned: 1
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(f d)
+(20 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(d)
+   NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(d)
+   NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   NO_GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Disabled: true
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(14 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
new file mode 100644
index 00000000000..e87652370c3
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -0,0 +1,292 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   HASH_JOIN(d1 d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (d1.id = f.dim1_id)
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Hash
+               ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 f d2)
+   HASH_JOIN(f d2)
+   SEQ_SCAN(d1 f d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN(d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Disabled: true
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_PLAIN(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
new file mode 100644
index 00000000000..71ee26a337a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -0,0 +1,297 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Disabled: true
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Materialize
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MATERIALIZE(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Memoize
+         Cache Key: f.dim_id
+         Cache Mode: logical
+         ->  Index Scan using join_dim_pkey on join_dim d
+               Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MEMOIZE(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN              
+-------------------------------------
+ Hash Join
+   Hash Cond: (d.id = f.dim_id)
+   ->  Seq Scan on join_dim d
+   ->  Hash
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   HASH_JOIN(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Materialize
+         ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_MATERIALIZE(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_dim d
+   ->  Materialize
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MATERIALIZE(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Memoize
+         Cache Key: d.id
+         Cache Mode: logical
+         ->  Index Scan using join_fact_dim_id on join_fact f
+               Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MEMOIZE(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+         Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_PLAIN(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(13 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/local_collector.out b/contrib/pg_plan_advice/expected/local_collector.out
new file mode 100644
index 00000000000..ac5aecd656f
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/local_collector.out
@@ -0,0 +1,64 @@
+CREATE EXTENSION pg_plan_advice;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
new file mode 100644
index 00000000000..df0f05531d5
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -0,0 +1,243 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt2.id)
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1b pt1_2
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1c pt1_3
+               Filter: (val1 = 1)
+   ->  Hash
+         ->  Hash Join
+               Hash Cond: (pt2.id = pt3.id)
+               ->  Append
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+               ->  Hash
+                     ->  Append
+                           ->  Seq Scan on pt3a pt3_1
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3b pt3_2
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3c pt3_3
+                                 Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE(pt1) /* matched */
+   PARTITIONWISE(pt2) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 (pt2 pt3))
+   HASH_JOIN(pt3 pt3)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE(pt1 pt2 pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(40 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt3.id)
+   ->  Append
+         ->  Hash Join
+               Hash Cond: (pt1_1.id = pt2_1.id)
+               ->  Seq Scan on pt1a pt1_1
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_2.id = pt2_2.id)
+               ->  Seq Scan on pt1b pt1_2
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_3.id = pt2_3.id)
+               ->  Seq Scan on pt1c pt1_3
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+   ->  Hash
+         ->  Append
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3b pt3_2
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3c pt3_3
+                     Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+   JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+   JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+   JOIN_ORDER({pt1 pt2} pt3)
+   HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+   SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+    pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE((pt1 pt2) pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+         ->  Seq Scan on pt1b pt1_2
+         ->  Seq Scan on pt1c pt1_3
+   ->  Append
+         ->  Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+               Index Cond: (id = pt1.id)
+         ->  Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+               Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 ptmismatch)
+   NESTED_LOOP_PLAIN(ptmismatch)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+    ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+   PARTITIONWISE(pt1 ptmismatch)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+    ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
new file mode 100644
index 00000000000..61f361fcf9c
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -0,0 +1,757 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+       QUERY PLAN        
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                     QUERY PLAN                     
+----------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+            QUERY PLAN             
+-----------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_b) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a > 0)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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 > 0;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a > 0)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (a > 0)
+   ->  Bitmap Index Scan on scan_table_pkey
+         Index Cond: (a > 0)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(9 rows)
+
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_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:
+   TID_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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table cilbup.scan_table_pkey) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, conflicting */
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched, conflicting */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Nested Loop Left Join
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s s#2)
+   INDEX_SCAN(s public.scan_table_pkey s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop Left Join
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s#2)
+   HASH_JOIN(s)
+   SEQ_SCAN(s)
+   INDEX_SCAN(s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s)
+   HASH_JOIN(s#2)
+   SEQ_SCAN(s#2)
+   INDEX_SCAN(s public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   HASH_JOIN(s s#2)
+   SEQ_SCAN(s s#2)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+COMMIT;
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(5 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(5 rows)
+
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+          QUERY PLAN           
+-------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@x)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                    QUERY PLAN                    
+--------------------------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/syntax.out b/contrib/pg_plan_advice/expected/syntax.out
new file mode 100644
index 00000000000..dddb12cae58
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/syntax.out
@@ -0,0 +1,59 @@
+LOAD 'pg_plan_advice';
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQUENTIAL_SCAN(x)"
+DETAIL:  Could not parse advice: syntax error at or near "SEQUENTIAL_SCAN"
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN"
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(""
+DETAIL:  Could not parse advice: unterminated quoted identifier at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(#"
+DETAIL:  Could not parse advice: syntax error at or near "#"
+SET pg_plan_advice.advice = '()';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "()"
+DETAIL:  Could not parse advice: syntax error at or near "("
+SET pg_plan_advice.advice = '123';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "123"
+DETAIL:  Could not parse advice: syntax error at or near "123"
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "JOIN_ORDER("fOO") /* oops"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*/* stuff */*/"
+DETAIL:  Could not parse advice: syntax error at or near "*"
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN(a)"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN((a))"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
new file mode 100644
index 00000000000..3452e5ad48e
--- /dev/null
+++ b/contrib/pg_plan_advice/meson.build
@@ -0,0 +1,70 @@
+# Copyright (c) 2022-2024, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+  'pg_plan_advice.c',
+  'pgpa_ast.c',
+  'pgpa_collector.c',
+  'pgpa_identifier.c',
+  'pgpa_join.c',
+  'pgpa_output.c',
+  'pgpa_planner.c',
+  'pgpa_scan.c',
+  'pgpa_trove.c',
+  'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+  input: 'pgpa_scanner.l',
+  output: 'pgpa_scanner.c',
+  command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+  input: 'pgpa_parser.y',
+  kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+  pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_plan_advice',
+    '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice = shared_module('pg_plan_advice',
+  pg_plan_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_data(
+  'pg_plan_advice--1.0.sql',
+  'pg_plan_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gather',
+      'join_order',
+      'join_strategy',
+      'local_collector',
+      'partitionwise',
+      'scan',
+      'syntax',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_regress.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice--1.0.sql b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
new file mode 100644
index 00000000000..29f4f224864
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
@@ -0,0 +1,42 @@
+/* contrib/pg_plan_advice/pg_plan_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_plan_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_plan_advice/pg_plan_advice.c b/contrib/pg_plan_advice/pg_plan_advice.c
new file mode 100644
index 00000000000..f32e8b7a0d3
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.c
@@ -0,0 +1,454 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ *	  main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static pgpa_shared_state *pgpa_state = NULL;
+static dsa_area *pgpa_dsa_area = NULL;
+
+/* GUC variables */
+char	   *pg_plan_advice_advice = NULL;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+int			pg_plan_advice_local_collection_limit = 0;
+int			pg_plan_advice_shared_collection_limit = 0;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int	es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+												  DefElem *opt,
+												  ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+												 IntoClause *into,
+												 ExplainState *es,
+												 const char *queryString,
+												 ParamListInfo params,
+												 QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+											 GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("pg_plan_advice.advice",
+							   "advice to apply during query planning",
+							   NULL,
+							   &pg_plan_advice_advice,
+							   NULL,
+							   PGC_USERSET,
+							   0,
+							   pg_plan_advice_advice_check_hook,
+							   NULL,
+							   NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+							 "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+							 NULL,
+							 &pg_plan_advice_always_explain_supplied_advice,
+							 true,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_plan_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_plan_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_plan_advice");
+
+	/* Get an ID that we can use to cache data in an ExplainState. */
+	es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+	/* Register the new EXPLAIN options implemented by this module. */
+	RegisterExtensionExplainOption("plan_advice",
+								   pg_plan_advice_explain_option_handler);
+
+	/* Install hooks */
+	pgpa_planner_install_hooks();
+	prev_explain_per_plan = explain_per_plan_hook;
+	explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgpa_init_shared_state(void *ptr)
+{
+	pgpa_shared_state *state = (pgpa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock, LWLockNewTrancheId("pg_plan_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_plan_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+	if (pgpa_memory_context == NULL)
+		pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_plan_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgpa_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ *
+ * Along the way, make sure the relevant LWLock tranches are registered.
+ */
+pgpa_shared_state *
+pg_plan_advice_attach(void)
+{
+	if (pgpa_state == NULL)
+	{
+		bool		found;
+
+		pgpa_state =
+			GetNamedDSMSegment("pg_plan_advice", sizeof(pgpa_shared_state),
+							   pgpa_init_shared_state, &found);
+	}
+
+	return pgpa_state;
+}
+
+/*
+ * Return a pointer to pg_plan_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_plan_advice_dsa_area(void)
+{
+	if (pgpa_dsa_area == NULL)
+	{
+		pgpa_shared_state *state = pg_plan_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgpa_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgpa_dsa_area);
+			state->area = dsa_get_handle(pgpa_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgpa_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgpa_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgpa_dsa_area;
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+									  ParseState *pstate)
+{
+	bool	   *plan_advice;
+
+	plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+	if (plan_advice == NULL)
+	{
+		plan_advice = palloc0_object(bool);
+		SetExplainExtensionState(es, es_extension_id, plan_advice);
+	}
+
+	*plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+									  char *value)
+{
+	char	   *s;
+
+	/* For non-text formats, it's best not to add any special handling. */
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		ExplainPropertyText(qlabel, value, es);
+		return;
+	}
+
+	/* In text format, if there is no data, display nothing. */
+	if (*qlabel == '\0')
+		return;
+
+	/*
+	 * It looks nicest to indent each line of the advice separately, beginning
+	 * on the line below the label.
+	 */
+	ExplainIndentText(es);
+	appendStringInfo(es->str, "%s:\n", qlabel);
+	es->indent++;
+	while ((s = strchr(value, '\n')) != NULL)
+	{
+		ExplainIndentText(es);
+		appendBinaryStringInfo(es->str, value, (s - value) + 1);
+		value = s + 1;
+	}
+
+	/* Don't interpret a terminal newline as a request for an empty line. */
+	if (*value != '\0')
+	{
+		ExplainIndentText(es);
+		appendStringInfo(es->str, "%s\n", value);
+	}
+
+	es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		appendStringInfo(&buf, "%s /* ", item->defname);
+		if ((flags & PGPA_TE_MATCH_FULL) != 0)
+		{
+			Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+			appendStringInfo(&buf, "matched");
+		}
+		else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+			appendStringInfo(&buf, "partially matched");
+		else
+			appendStringInfo(&buf, "not matched");
+		if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+			appendStringInfo(&buf, ", inapplicable");
+		if ((flags & PGPA_TE_CONFLICTING) != 0)
+			appendStringInfo(&buf, ", conflicting");
+		if ((flags & PGPA_TE_FAILED) != 0)
+			appendStringInfo(&buf, ", failed");
+		appendStringInfo(&buf, " */\n");
+	}
+
+	pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+										  buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+									 IntoClause *into,
+									 ExplainState *es,
+									 const char *queryString,
+									 ParamListInfo params,
+									 QueryEnvironment *queryEnv)
+{
+	bool	   *plan_advice = GetExplainExtensionState(es, es_extension_id);
+	DefElem    *pgpa_item;
+	List	   *pgpa_list;
+
+	if (prev_explain_per_plan)
+		prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+							  queryEnv);
+
+	/* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+	pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+										"pg_plan_advice");
+	pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+	/*
+	 * By default, if there is a record of attempting to apply advice during
+	 * query planning, we always output that information, but the user can set
+	 * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+	 * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+	 * was specified and not set to false.
+	 *
+	 * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+	 * statement -- the application of query advice may not have been
+	 * recorded, and therefore this won't be able to show anything.
+	 */
+	if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+							  (plan_advice != NULL && *plan_advice)))
+	{
+		DefElem    *feedback;
+
+		feedback = find_defelem_by_defname(pgpa_list, "feedback");
+		if (feedback != NULL)
+			pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+	}
+
+	/*
+	 * If the PLAN_ADVICE option was specified -- and not sent to FALSE --
+	 * show generated advice.
+	 */
+	if (plan_advice != NULL && *plan_advice)
+	{
+		DefElem    *advice_string_item;
+		char	   *advice_string;
+
+		advice_string_item =
+			find_defelem_by_defname(pgpa_list, "advice_string");
+		if (advice_string_item != NULL)
+		{
+			/* Advice has already been generated; we can reuse it. */
+			advice_string = strVal(advice_string_item->arg);
+		}
+		else
+		{
+			pgpa_plan_walker_context walker;
+			StringInfoData buf;
+			pgpa_identifier *rt_identifiers;
+
+			/* Advice not yet generated; do that now. */
+			pgpa_plan_walker(&walker, plannedstmt);
+			rt_identifiers =
+				pgpa_create_identifiers_for_planned_stmt(plannedstmt);
+			initStringInfo(&buf);
+			pgpa_output_advice(&buf, &walker, rt_identifiers);
+			advice_string = buf.data;
+		}
+
+		if (advice_string[0] != '\0')
+			pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+												  advice_string);
+	}
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+	MemoryContext oldcontext;
+	MemoryContext tmpcontext;
+	char	   *error;
+
+	if (*newval == NULL)
+		return true;
+
+	tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+									   "pg_plan_advice.advice",
+									   ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+	/*
+	 * It would be nice to save the parse tree that we construct here for
+	 * eventual use when planning with this advice, but *extra can only point
+	 * to a single guc_malloc'd chunk, and our parse tree involves an
+	 * arbitrary number of memory allocations.
+	 */
+	(void) pgpa_parse(*newval, &error);
+
+	if (error != NULL)
+	{
+		GUC_check_errdetail("Could not parse advice: %s", error);
+		return false;
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+	MemoryContextDelete(tmpcontext);
+
+	return true;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.control b/contrib/pg_plan_advice/pg_plan_advice.control
new file mode 100644
index 00000000000..aa6fdc9e7b2
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.control
@@ -0,0 +1,5 @@
+# pg_plan_advice extension
+comment = 'help the planner get the right plan'
+default_version = '1.0'
+module_pathname = '$libdir/pg_plan_advice'
+relocatable = true
diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
new file mode 100644
index 00000000000..86efb3b6113
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -0,0 +1,37 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ *	  main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "nodes/plannodes.h"
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgpa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgpa_shared_state;
+
+/* GUC variables */
+extern int	pg_plan_advice_local_collection_limit;
+extern int	pg_plan_advice_shared_collection_limit;
+extern char *pg_plan_advice_advice;
+
+/* Function prototypes */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern pgpa_shared_state *pg_plan_advice_attach(void);
+extern dsa_area *pg_plan_advice_dsa_area(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
new file mode 100644
index 00000000000..02ffbfa3760
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -0,0 +1,392 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ *	  additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+										  pgpa_advice_target *target,
+										  bool *rids_used);
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+	switch (advice_tag)
+	{
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_FOREIGN_JOIN:
+			return "FOREIGN_JOIN";
+		case PGPA_TAG_GATHER:
+			return "GATHER";
+		case PGPA_TAG_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPA_TAG_HASH_JOIN:
+			return "HASH_JOIN";
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_TAG_INDEX_SCAN:
+			return "INDEX_SCAN";
+		case PGPA_TAG_JOIN_ORDER:
+			return "JOIN_ORDER";
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case PGPA_TAG_NO_GATHER:
+			return "NO_GATHER";
+		case PGPA_TAG_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+		case PGPA_TAG_SEQ_SCAN:
+			return "SEQ_SCAN";
+		case PGPA_TAG_TID_SCAN:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and reurn an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+	*fail = false;
+
+	switch (tag[0])
+	{
+		case 'b':
+			if (strcmp(tag, "bitmap_heap_scan") == 0)
+				return PGPA_TAG_BITMAP_HEAP_SCAN;
+			break;
+		case 'f':
+			if (strcmp(tag, "foreign_join") == 0)
+				return PGPA_TAG_FOREIGN_JOIN;
+			break;
+		case 'g':
+			if (strcmp(tag, "gather") == 0)
+				return PGPA_TAG_GATHER;
+			if (strcmp(tag, "gather_merge") == 0)
+				return PGPA_TAG_GATHER_MERGE;
+			break;
+		case 'h':
+			if (strcmp(tag, "hash_join") == 0)
+				return PGPA_TAG_HASH_JOIN;
+			break;
+		case 'i':
+			if (strcmp(tag, "index_scan") == 0)
+				return PGPA_TAG_INDEX_SCAN;
+			if (strcmp(tag, "index_only_scan") == 0)
+				return PGPA_TAG_INDEX_ONLY_SCAN;
+			break;
+		case 'j':
+			if (strcmp(tag, "join_order") == 0)
+				return PGPA_TAG_JOIN_ORDER;
+			break;
+		case 'm':
+			if (strcmp(tag, "merge_join_materialize") == 0)
+				return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+			if (strcmp(tag, "merge_join_plain") == 0)
+				return PGPA_TAG_MERGE_JOIN_PLAIN;
+			break;
+		case 'n':
+			if (strcmp(tag, "nested_loop_materialize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+			if (strcmp(tag, "nested_loop_memoize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+			if (strcmp(tag, "nested_loop_plain") == 0)
+				return PGPA_TAG_NESTED_LOOP_PLAIN;
+			if (strcmp(tag, "no_gather") == 0)
+				return PGPA_TAG_NO_GATHER;
+			break;
+		case 'p':
+			if (strcmp(tag, "partitionwise") == 0)
+				return PGPA_TAG_PARTITIONWISE;
+			break;
+		case 's':
+			if (strcmp(tag, "semijoin_non_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+			if (strcmp(tag, "semijoin_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_UNIQUE;
+			if (strcmp(tag, "seq_scan") == 0)
+				return PGPA_TAG_SEQ_SCAN;
+			break;
+		case 't':
+			if (strcmp(tag, "tid_scan") == 0)
+				return PGPA_TAG_TID_SCAN;
+			break;
+	}
+
+	/* didn't work out */
+	*fail = true;
+
+	/* return an arbitrary value to unwind the call stack */
+	return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		bool		first = true;
+		char	   *delims;
+
+		if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+			delims = "{}";
+		else
+			delims = "()";
+
+		appendStringInfoChar(str, delims[0]);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_advice_target(str, child_target);
+		}
+		appendStringInfoChar(str, delims[1]);
+	}
+	else
+	{
+		const char *rt_identifier;
+
+		rt_identifier = pgpa_identifier_string(&target->rid);
+		appendStringInfoString(str, rt_identifier);
+	}
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+	if (itarget->itype != PGPA_INDEX_NAME)
+	{
+		bool		first = true;
+
+		if (itarget->itype == PGPA_INDEX_AND)
+			appendStringInfoString(str, "&&(");
+		else
+			appendStringInfoString(str, "||(");
+
+		foreach_ptr(pgpa_index_target, child_target, itarget->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_index_target(str, child_target);
+		}
+		appendStringInfoChar(str, ')');
+	}
+	else
+	{
+		if (itarget->indnamespace != NULL)
+			appendStringInfo(str, "%s.",
+							 quote_identifier(itarget->indnamespace));
+		appendStringInfoString(str, quote_identifier(itarget->indname));
+	}
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+	if (i1->itype != i2->itype)
+		return false;
+
+	if (i1->itype == PGPA_INDEX_NAME)
+	{
+		/* indnamespace can be NULL, and two NULL values are equal */
+		if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+			(i1->indnamespace == NULL || i2->indnamespace == NULL ||
+			 strcmp(i1->indnamespace, i2->indnamespace) != 0))
+			return false;
+		if (strcmp(i1->indname, i2->indname) != 0)
+			return false;
+	}
+	else
+	{
+		int			i1_length = list_length(i1->children);
+
+		if (i1_length != list_length(i2->children))
+			return false;
+		for (int n = 0; n < i1_length; ++n)
+		{
+			pgpa_index_target *c1 = list_nth(i1->children, n);
+			pgpa_index_target *c2 = list_nth(i2->children, n);
+
+			if (!pgpa_index_targets_equal(c1, c2))
+				return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+	/* For non-identifiers, check all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (pgpa_identifier_matches_target(rid, child_target))
+				return true;
+		}
+		return false;
+	}
+
+	if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+		return false;
+	if (rid->occurrence != target->rid.occurrence)
+		return false;
+
+	/*
+	 * The identifier must specify a schema, but the target may leave the
+	 * schema NULL to match anything.
+	 */
+	if (target->rid.partnsp != NULL &&
+		strcmp(rid->partnsp, target->rid.partnsp) != 0)
+		return false;
+
+
+	/*
+	 * These fields can be NULL on either side, but NULL only matches another
+	 * NULL.
+	 */
+	if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+		return false;
+	if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+		return false;
+
+	return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target)
+{
+	bool		all_rids_used = true;
+	bool		any_rids_used = false;
+	bool		all_targets_used;
+	bool	   *rids_used = palloc0_array(bool, nrids);
+
+	all_targets_used =
+		pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+	for (int i = 0; i < nrids; ++i)
+	{
+		if (rids_used[i])
+			any_rids_used = true;
+		else
+			all_rids_used = false;
+	}
+
+	if (all_rids_used)
+	{
+		if (all_targets_used)
+			return PGPA_ITM_EQUAL;
+		else
+			return PGPA_ITM_KEYS_ARE_SUBSET;
+	}
+	else
+	{
+		if (all_targets_used)
+			return PGPA_ITM_TARGETS_ARE_SUBSET;
+		else if (any_rids_used)
+			return PGPA_ITM_INTERSECTING;
+		else
+			return PGPA_ITM_DISJOINT;
+	}
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target, bool *rids_used)
+{
+	bool		result = false;
+
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		result = true;
+
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+											   rids_used))
+				result = false;
+		}
+	}
+	else
+	{
+		for (int i = 0; i < nrids; ++i)
+		{
+			if (pgpa_identifier_matches_target(&rids[i], target))
+			{
+				rids_used[i] = true;
+				result = true;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
new file mode 100644
index 00000000000..f6fe730a4d4
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -0,0 +1,204 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ *	  abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+	PGPA_TARGET_IDENTIFIER,		/* relation identifier */
+	PGPA_TARGET_ORDERED_LIST,	/* (item ...) */
+	PGPA_TARGET_UNORDERED_LIST	/* {item ...} */
+} pgpa_target_type;
+
+/*
+ * When an advice item describes a bitmap index scan, it may need to describe
+ * the use of multiple indexes.
+ */
+typedef enum
+{
+	PGPA_INDEX_NAME,			/* index schema + name */
+	PGPA_INDEX_AND,				/* &&(item ...) */
+	PGPA_INDEX_OR				/* ||(item ...) */
+} pgpa_index_type;
+
+/*
+ * An index specification. We use this for INDEX_SCAN, INDEX_ONLY_SCAN,
+ * and BITMAP_HEAP_SCAN advice, but in the former two cases, the target must
+ * be of type PGPA_INDEX_NAME.
+ */
+typedef struct pgpa_index_target
+{
+	pgpa_index_type itype;
+
+	/* Index schem and name, when itype == PGPA_INDEX_NAME */
+	char	   *indnamespace;
+	char	   *indname;
+
+	/* List of pgpa_index_target objects, when itype != PGPA_INDEX_NAME */
+	List	   *children;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+	pgpa_target_type ttype;
+
+	/*
+	 * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+	 *
+	 * All identifiers must have an alias name and an occurrence number; the
+	 * remaining fields can be NULL. Note that it's possible to specify a
+	 * partition name without a partition schema, but not the reverse.
+	 */
+	pgpa_identifier rid;
+
+	/*
+	 * This field is set when ttype is PPGA_TARGET_IDENTIFIER and the advice
+	 * tag is PGPA_TAG_INDEX_SCAN, PGPA_TAG_INDEX_ONLY_SCAN, or
+	 * PGPA_TAG_BITMAP_HEAP_SCAN.
+	 */
+	pgpa_index_target *itarget;
+
+	/*
+	 * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+	 * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+	 */
+	List	   *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_FOREIGN_JOIN,
+	PGPA_TAG_GATHER,
+	PGPA_TAG_GATHER_MERGE,
+	PGPA_TAG_HASH_JOIN,
+	PGPA_TAG_INDEX_ONLY_SCAN,
+	PGPA_TAG_INDEX_SCAN,
+	PGPA_TAG_JOIN_ORDER,
+	PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+	PGPA_TAG_MERGE_JOIN_PLAIN,
+	PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+	PGPA_TAG_NESTED_LOOP_MEMOIZE,
+	PGPA_TAG_NESTED_LOOP_PLAIN,
+	PGPA_TAG_NO_GATHER,
+	PGPA_TAG_PARTITIONWISE,
+	PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+	PGPA_TAG_SEMIJOIN_UNIQUE,
+	PGPA_TAG_SEQ_SCAN,
+	PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+	pgpa_advice_tag_type tag;
+	List	   *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by an
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifeirs and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifeirs and targets were
+ * found.
+ */
+typedef enum
+{
+	PGPA_ITM_EQUAL,
+	PGPA_ITM_KEYS_ARE_SUBSET,
+	PGPA_ITM_TARGETS_ARE_SUBSET,
+	PGPA_ITM_INTERSECTING,
+	PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int	pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+					   char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner,
+						 const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int	pgpa_yyparse(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+										   pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+												   pgpa_identifier *rids,
+												   pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+									 pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+									  pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+									 pgpa_index_target *itarget);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_collector.c b/contrib/pg_plan_advice/pgpa_collector.c
new file mode 100644
index 00000000000..12085d9d75f
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.c
@@ -0,0 +1,637 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.c
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgpa_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgpa_collected_advice;
+
+/*
+ * A bunch of pointers to pgpa_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgpa_local_advice_chunk
+{
+	pgpa_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgpa_local_advice_chunk;
+
+/*
+ * Information about all of the pgpa_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgpa_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgpa_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgpa_local_advice_chunk **chunks;
+} pgpa_local_advice;
+
+/*
+ * Just like pgpa_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgpa_shared_advice_chunk;
+
+/*
+ * Just like pgpa_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgpa_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgpa_local_advice *local_collector = NULL;
+static pgpa_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgpa_collected_advice *pgpa_make_collected_advice(Oid userid,
+														 Oid dbid,
+														 uint64 queryId,
+														 TimestampTz timestamp,
+														 const char *query_string,
+														 const char *advice_string,
+														 dsa_area *area,
+														 dsa_pointer *result);
+static void pgpa_store_local_advice(pgpa_collected_advice *ca);
+static void pgpa_trim_local_advice(int limit);
+static void pgpa_store_shared_advice(dsa_pointer ca_pointer);
+static void pgpa_trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgpa_collected_advice */
+static inline const char *
+query_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgpa_collected_advice */
+static inline const char *
+advice_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pgpa_collect_advice(uint64 queryId, const char *query_string,
+					const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_plan_advice_local_collection_limit > 0)
+	{
+		pgpa_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+		ca = pgpa_make_collected_advice(userid, dbid, queryId, now,
+										query_string, advice_string,
+										NULL, NULL);
+		pgpa_store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_plan_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_plan_advice_dsa_area();
+		dsa_pointer ca_pointer;
+
+		pgpa_make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string, area,
+								   &ca_pointer);
+		pgpa_store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgpa_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgpa_collected_advice *
+pgpa_make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+						   TimestampTz timestamp,
+						   const char *query_string,
+						   const char *advice_string,
+						   dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgpa_collected_advice *ca;
+
+	total_length = offsetof(pgpa_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = GetUserId();
+	ca->dbid = MyDatabaseId;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pg_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+pgpa_store_local_advice(pgpa_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgpa_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgpa_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgpa_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgpa_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_local_advice(pg_plan_advice_local_collection_limit);
+}
+
+/*
+ * Add a pg_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_plan_advice DSA area
+ * and should point to an object of type pgpa_collected_advice.
+ */
+static void
+pgpa_store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	pgpa_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgpa_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgpa_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_shared_advice(area, pg_plan_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_local_advice(int limit)
+{
+	pgpa_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgpa_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgpa_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_shared_advice(dsa_area *area, int limit)
+{
+	pgpa_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(pgpa_shared_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		pgpa_trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	pgpa_trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice *sa = shared_collector;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_plan_advice/pgpa_collector.h b/contrib/pg_plan_advice/pgpa_collector.h
new file mode 100644
index 00000000000..b6e746a06d7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.h
@@ -0,0 +1,18 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.h
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_COLLECTOR_H
+#define PGPA_COLLECTOR_H
+
+extern void pgpa_collect_advice(uint64 queryId, const char *query_string,
+								const char *advice_string);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_identifier.c b/contrib/pg_plan_advice/pgpa_identifier.c
new file mode 100644
index 00000000000..2fa8075d66e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.c
@@ -0,0 +1,476 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ *	  create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted.  Identifier-style escaping is applied to alias_name and
+ * subquery_name.  Whenever we include child_table_name, we always
+ * schema-qualified name, but writing their own plan advice are not required
+ * to do so.  Identifier-style escaping is applied to the schema and to the
+ * relation names separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+									  List *appinfos);
+static int	pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+								   SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+	const char *result;
+
+	Assert(rid->alias_name != NULL);
+	result = quote_identifier(rid->alias_name);
+
+	Assert(rid->occurrence >= 0);
+	if (rid->occurrence > 1)
+		result = psprintf("%s#%d", result, rid->occurrence);
+
+	if (rid->partrel != NULL)
+	{
+		if (rid->partnsp == NULL)
+			result = psprintf("%s/%s", result,
+							  quote_identifier(rid->partnsp));
+		else
+			result = psprintf("%s/%s.%s", result,
+							  quote_identifier(rid->partnsp),
+							  quote_identifier(rid->partrel));
+	}
+
+	if (rid->plan_name != NULL)
+		result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+	return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+							   pgpa_identifier *rid)
+{
+	Index		top_rti = rti;
+	int			occurrence = 1;
+	RangeTblEntry *rte;
+	RangeTblEntry *top_rte;
+	char	   *partnsp = NULL;
+	char	   *partrel = NULL;
+
+	/*
+	 * If this is a child RTE, find the topmost parent that is still of type
+	 * RTE_RELATION. We do this because we identify children of partitioned
+	 * tables by the name of the child table, but subqueries can also have
+	 * child rels and we don't care about those here.
+	 */
+	for (;;)
+	{
+		AppendRelInfo *appinfo;
+		RangeTblEntry *parent_rte;
+
+		/* append_rel_array can be NULL if there are no children */
+		if (root->append_rel_array == NULL ||
+			(appinfo = root->append_rel_array[top_rti]) == NULL)
+			break;
+
+		parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+		if (parent_rte->rtekind != RTE_RELATION)
+			break;
+
+		top_rti = appinfo->parent_relid;
+	}
+
+	/* Get the range table entries for the RTI and top RTI. */
+	rte = planner_rt_fetch(rti, root);
+	top_rte = planner_rt_fetch(top_rti, root);
+	Assert(rte->rtekind != RTE_JOIN);
+	Assert(top_rte->rtekind != RTE_JOIN);
+
+	/* Work out the correct occurrence number. */
+	for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+		AppendRelInfo *appinfo;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 *
+		 * NB: append_rel_array can be NULL if there are no children
+		 */
+		if (root->append_rel_array != NULL &&
+			(appinfo = root->append_rel_array[prior_rti]) != NULL)
+		{
+			RangeTblEntry *parent_rte;
+
+			parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+			if (parent_rte->rtekind == RTE_RELATION)
+				continue;
+		}
+
+		/* Skip NULL entries and joins. */
+		prior_rte = planner_rt_fetch(prior_rti, root);
+		if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	/* If this is a child table, get the schema and relation names. */
+	if (rti != top_rti)
+	{
+		partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+		partrel = get_rel_name(rte->relid);
+	}
+
+	/* OK, we have all the answers we need. Return them to the caller. */
+	rid->alias_name = top_rte->eref->aliasname;
+	rid->occurrence = occurrence;
+	rid->partnsp = partnsp;
+	rid->partrel = partrel;
+	rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+								   pgpa_identifier *rids)
+{
+	int			count = 0;
+	int			rti = -1;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = planner_rt_fetch(rti, root);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+		pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+	}
+
+	Assert(count > 0);
+	return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+	Index		rtable_length = list_length(pstmt->rtable);
+	pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+	Index	   *top_rti_map;
+	int			rtinfoindex = 0;
+	SubPlanRTInfo *rtinfo = NULL;
+	SubPlanRTInfo *nextrtinfo = NULL;
+
+	/*
+	 * Account for relations addded by inheritance expansion of partitioned
+	 * tables.
+	 */
+	top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+										  pstmt->appendRelations);
+
+	/*
+	 * When we begin iterating, we're processing the portion of the range
+	 * table that originated from the top-level PlannerInfo, so subrtinfo is
+	 * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+	 * portion of the range table we are processing. nextrtinfo is always the
+	 * SubPlanRTInfo that follows the current one, if any, so when we're
+	 * processing the top-level query's portion of the range table, the next
+	 * SubPlanRTInfo is the very first one.
+	 */
+	if (pstmt->subrtinfos != NULL)
+		nextrtinfo = linitial(pstmt->subrtinfos);
+
+	/* Main loop over the range table. */
+	for (Index rti = 1; rti <= rtable_length; rti++)
+	{
+		const char *plan_name;
+		Index		top_rti;
+		RangeTblEntry *rte;
+		RangeTblEntry *top_rte;
+		char	   *partnsp = NULL;
+		char	   *partrel = NULL;
+		int			occurrence;
+		pgpa_identifier *rid;
+
+		/*
+		 * Advance to the next SubPlanRTInfo, if it's time to do that.
+		 *
+		 * This loop probably shouldn't ever iterate more than once, because
+		 * that would imply that a subquery was planned but added nothing to
+		 * the range table; but let's be defensive and assume it can happen.
+		 */
+		while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+		{
+			rtinfo = nextrtinfo;
+			if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+				nextrtinfo = NULL;
+			else
+				nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+		}
+
+		/* Fetch the range table entry, if any. */
+		rte = rt_fetch(rti, pstmt->rtable);
+
+		/*
+		 * We can't and don't need to identify null entries, and we don't want
+		 * to identify join entries.
+		 */
+		if (rte == NULL || rte->rtekind == RTE_JOIN)
+			continue;
+
+		/*
+		 * If this is not a relation added by partitioned table expansion,
+		 * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+		 * we need the information for the top RTI/RTE, and must also fetch
+		 * the partition schema and name.
+		 */
+		top_rti = top_rti_map[rti - 1];
+		if (rti == top_rti)
+			top_rte = rte;
+		else
+		{
+			top_rte = rt_fetch(top_rti, pstmt->rtable);
+			partnsp =
+				get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+			partrel = get_rel_name(rte->relid);
+		}
+
+		/* Compute the correct occurrence number. */
+		occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+											rtinfo, top_rti);
+
+		/* Get the name of the current plan (NULL for toplevel query). */
+		plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+		/* Save all the details we've derived. */
+		rid = &result[rti - 1];
+		rid->alias_name = top_rte->eref->aliasname;
+		rid->occurrence = occurrence;
+		rid->partnsp = partnsp;
+		rid->partrel = partrel;
+		rid->plan_name = plan_name;
+	}
+
+	return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid)
+{
+	Index		result = 0;
+
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+	{
+		pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+		/* If there's no identifier for this RTI, skip it. */
+		if (rti_rid->alias_name == NULL)
+			continue;
+
+		/*
+		 * If it matches, return this RTI. As usual, an omitted partition
+		 * schema matches anything, but partition and plan names must either
+		 * match exactly or be omitted on both sides.
+		 */
+		if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+			rid->occurrence == rti_rid->occurrence &&
+			(rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+			 strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+			strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+			strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+		{
+			if (result != 0)
+			{
+				/* Multiple matches were found. */
+				return 0;
+			}
+			result = rti;
+		}
+	}
+
+	return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+	Index	   *top_rti_map = palloc0_array(Index, rtable_length);
+
+	/* Initially, make every RTI point to itself. */
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+		top_rti_map[rti - 1] = rti;
+
+	/* Update the map for each AppendRelInfo object. */
+	foreach_node(AppendRelInfo, appinfo, appinfos)
+	{
+		Index		parent_rti = appinfo->parent_relid;
+		RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+		/* If the parent is not RTE_RELATION, ignore this entry. */
+		if (parent_rte->rtekind != RTE_RELATION)
+			continue;
+
+		/*
+		 * Map the child to wherever we mapped the parent. Parents always
+		 * precede their children in the AppendRelInfo list, so this should
+		 * work out.
+		 */
+		top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+	}
+
+	return top_rti_map;
+}
+
+/*
+ * Find the occurence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+					   SubPlanRTInfo *rtinfo, Index rti)
+{
+	Index		rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+	int			occurrence = 1;
+	RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+	for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 */
+		if (top_rti_map[prior_rti - 1] != prior_rti)
+			break;
+
+		/* Skip joins. */
+		prior_rte = rt_fetch(prior_rti, rtable);
+		if (prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	return occurrence;
+}
diff --git a/contrib/pg_plan_advice/pgpa_identifier.h b/contrib/pg_plan_advice/pgpa_identifier.h
new file mode 100644
index 00000000000..b000d2b7081
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ *	  create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+	const char *alias_name;
+	int			occurrence;
+	const char *partnsp;
+	const char *partrel;
+	const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+	if (a == b)
+		return true;
+	else if (a == NULL || b == NULL)
+		return false;
+	else
+		return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+										   pgpa_identifier *rid);
+extern int	pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+											   Bitmapset *relids,
+											   pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+											  pgpa_identifier *rt_identifiers,
+											  pgpa_identifier *rid);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
new file mode 100644
index 00000000000..28618764d86
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -0,0 +1,615 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	Plan	   *outer_subplan;
+	ElidedNode *outer_elided_node;
+	bool		outer_beneath_any_gather;
+	pgpa_join_strategy *strategy;
+	Plan	  **inner_subplans;
+	ElidedNode **inner_elided_nodes;
+	pgpa_join_unroller **inner_unrollers;
+	bool	   *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+											  Plan *plan,
+											  Plan **realouter,
+											  Plan **realinner,
+											  ElidedNode **elidedrealouter,
+											  ElidedNode **elidedrealinner,
+											  bool *found_any_outer_gather,
+											  bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+										   bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+									ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+	pgpa_join_unroller *join_unroller;
+
+	join_unroller = palloc0_object(pgpa_join_unroller);
+	join_unroller->nallocated = 4;
+	join_unroller->strategy =
+		palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+	join_unroller->inner_subplans =
+		palloc_array(Plan *, join_unroller->nallocated);
+	join_unroller->inner_elided_nodes =
+		palloc_array(ElidedNode *, join_unroller->nallocated);
+	join_unroller->inner_unrollers =
+		palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+	join_unroller->inner_beneath_any_gather =
+		palloc_array(bool, join_unroller->nallocated);
+
+	return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+				 bool beneath_any_gather,
+				 pgpa_join_unroller *join_unroller,
+				 pgpa_join_unroller **outer_join_unroller,
+				 pgpa_join_unroller **inner_join_unroller)
+{
+	pgpa_join_strategy strategy;
+	Plan	   *realinner,
+			   *realouter;
+	ElidedNode *elidedinner,
+			   *elidedouter;
+	int			n;
+	bool		found_any_outer_gather = false;
+	bool		found_any_inner_gather = false;
+
+	Assert(join_unroller != NULL);
+
+	/*
+	 * We need to pass the join_unroller object down through certain types of
+	 * plan nodes -- anything that's considered part of the join strategy, and
+	 * any other nodes that can occur in a join tree despite not being scans
+	 * or joins.
+	 *
+	 * This includes:
+	 *
+	 * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+	 * strategy,
+	 *
+	 * (2) Gather and Gather Merge nodes, which can occur at any point in the
+	 * join tree where the planner decided to initiate parallelism,
+	 *
+	 * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+	 * or GatherMerge,
+	 *
+	 * (4) Agg and Unique nodes, which can occur when we decide to make the
+	 * nullable side of a semijoin unique and then join the result, and
+	 *
+	 * (5) Result nodes with children, which can be added either to project to
+	 * enforce a one-time filter (but Result nodes without children are
+	 * degenerate scans or joins).
+	 */
+	if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+		|| IsA(plan, Gather) || IsA(plan, GatherMerge)
+		|| is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+		|| is_result_node_with_child(plan))
+	{
+		*outer_join_unroller = join_unroller;
+		return;
+	}
+
+	/*
+	 * Since we've already handled nodes that require pass-through treatment,
+	 * this should be an unrollable join.
+	 */
+	strategy = pgpa_decompose_join(walker, plan,
+								   &realouter, &realinner,
+								   &elidedouter, &elidedinner,
+								   &found_any_outer_gather,
+								   &found_any_inner_gather);
+
+	/* If our workspace is full, expand it. */
+	if (join_unroller->nused >= join_unroller->nallocated)
+	{
+		join_unroller->nallocated *= 2;
+		join_unroller->strategy =
+			repalloc_array(join_unroller->strategy,
+						   pgpa_join_strategy,
+						   join_unroller->nallocated);
+		join_unroller->inner_subplans =
+			repalloc_array(join_unroller->inner_subplans,
+						   Plan *,
+						   join_unroller->nallocated);
+		join_unroller->inner_elided_nodes =
+			repalloc_array(join_unroller->inner_elided_nodes,
+						   ElidedNode *,
+						   join_unroller->nallocated);
+		join_unroller->inner_beneath_any_gather =
+			repalloc_array(join_unroller->inner_beneath_any_gather,
+						   bool,
+						   join_unroller->nallocated);
+		join_unroller->inner_unrollers =
+			repalloc_array(join_unroller->inner_unrollers,
+						   pgpa_join_unroller *,
+						   join_unroller->nallocated);
+	}
+
+	/*
+	 * Since we're flattening outer-deep join trees, it follows that if the
+	 * outer side is still an unrollable join, it should be unrolled into this
+	 * same object. Otherwise, we've reached the limit of what we can unroll
+	 * into this object and must remember the outer side as the final outer
+	 * subplan.
+	 */
+	if (elidedouter == NULL && pgpa_is_join(realouter))
+		*outer_join_unroller = join_unroller;
+	else
+	{
+		join_unroller->outer_subplan = realouter;
+		join_unroller->outer_elided_node = elidedouter;
+		join_unroller->outer_beneath_any_gather =
+			beneath_any_gather || found_any_outer_gather;
+	}
+
+	/*
+	 * Store the inner subplan. If it's an unrollable join, it needs to be
+	 * flattened in turn, but into a new unroller object, not this one.
+	 */
+	n = join_unroller->nused++;
+	join_unroller->strategy[n] = strategy;
+	join_unroller->inner_subplans[n] = realinner;
+	join_unroller->inner_elided_nodes[n] = elidedinner;
+	join_unroller->inner_beneath_any_gather[n] =
+		beneath_any_gather || found_any_inner_gather;
+	if (elidedinner == NULL && pgpa_is_join(realinner))
+		*inner_join_unroller = pgpa_create_join_unroller();
+	else
+		*inner_join_unroller = NULL;
+	join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+						 pgpa_join_unroller *join_unroller)
+{
+	pgpa_unrolled_join *ujoin;
+	int			i;
+
+	/*
+	 * We shouldn't have gone even so far as to create a join unroller unless
+	 * we found at least one unrollable join.
+	 */
+	Assert(join_unroller->nused > 0);
+
+	/* Allocate result structures. */
+	ujoin = palloc0_object(pgpa_unrolled_join);
+	ujoin->ninner = join_unroller->nused;
+	ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+	ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+	/* Handle the outermost join. */
+	ujoin->outer.plan = join_unroller->outer_subplan;
+	ujoin->outer.elided_node = join_unroller->outer_elided_node;
+	ujoin->outer.scan =
+		pgpa_build_scan(walker, ujoin->outer.plan,
+						ujoin->outer.elided_node,
+						join_unroller->outer_beneath_any_gather,
+						true);
+
+	/*
+	 * We want the joins from the deepest part of the plan tree to appear
+	 * first in the result object, but the join unroller adds them in exactly
+	 * the reverse of that order, so we need to flip the order of the arrays
+	 * when constructing the final result.
+	 */
+	for (i = 0; i < join_unroller->nused; ++i)
+	{
+		int			k = join_unroller->nused - i - 1;
+
+		/* Copy strategy, Plan, and ElidedNode. */
+		ujoin->strategy[i] = join_unroller->strategy[k];
+		ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+		ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+		/*
+		 * Fill in remaining details, using either the nested join unroller,
+		 * or by deriving them from the plan and elided nodes.
+		 */
+		if (join_unroller->inner_unrollers[k] != NULL)
+			ujoin->inner[i].unrolled_join =
+				pgpa_build_unrolled_join(walker,
+										 join_unroller->inner_unrollers[k]);
+		else
+			ujoin->inner[i].scan =
+				pgpa_build_scan(walker, ujoin->inner[i].plan,
+								ujoin->inner[i].elided_node,
+								join_unroller->inner_beneath_any_gather[i],
+								true);
+	}
+
+	return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+	pfree(join_unroller->strategy);
+	pfree(join_unroller->inner_subplans);
+	pfree(join_unroller->inner_elided_nodes);
+	pfree(join_unroller->inner_unrollers);
+	pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort note on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any correspoding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+					Plan **realouter, Plan **realinner,
+					ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+					bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+	PlannedStmt *pstmt = walker->pstmt;
+	JoinType	jointype = ((Join *) plan)->jointype;
+	Plan	   *outerplan = plan->lefttree;
+	Plan	   *innerplan = plan->righttree;
+	ElidedNode *elidedouter;
+	ElidedNode *elidedinner;
+	pgpa_join_strategy strategy;
+	bool		uniqueouter;
+	bool		uniqueinner;
+
+	elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+	elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+	*found_any_outer_gather = false;
+	*found_any_inner_gather = false;
+
+	switch (nodeTag(plan))
+	{
+		case T_MergeJoin:
+
+			/*
+			 * The planner may have chosen to place a Material node on the
+			 * inner side of the MergeJoin; if this is present, we record it
+			 * as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+			}
+			else
+				strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+			/*
+			 * For a MergeJoin, either the outer or the inner subplan, or
+			 * both, may have needed to be sorted; we must disregard any Sort
+			 * or IncrementalSort node to find the real inner or outer
+			 * subplan.
+			 */
+			if (elidedouter == NULL && is_sorting_plan(outerplan))
+				elidedouter = pgpa_descend_node(pstmt, &outerplan);
+			if (elidedinner == NULL && is_sorting_plan(innerplan))
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			break;
+
+		case T_NestLoop:
+
+			/*
+			 * The planner may have chosen to place a Material or Memoize node
+			 * on the inner side of the NestLoop; if this is present, we
+			 * record it as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+			}
+			else if (elidedinner == NULL && IsA(innerplan, Memoize))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+			}
+			else
+				strategy = JSTRAT_NESTED_LOOP_PLAIN;
+			break;
+
+		case T_HashJoin:
+
+			/*
+			 * The inner subplan of a HashJoin is always a Hash node; the real
+			 * inner subplan is the Hash node's child.
+			 */
+			Assert(IsA(innerplan, Hash));
+			Assert(elidedinner == NULL);
+			elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			strategy = JSTRAT_HASH_JOIN;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+	}
+
+	/*
+	 * The planner may have decided to implement a semijoin by first making
+	 * the nullable side of the plan unique, and then performing a normal join
+	 * against the result. Therefore, we might need to descend through a
+	 * unique node on either side of the plan.
+	 */
+	uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+	uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+	/*
+	 * The planner may have decided to parallelize part of the join tree, so
+	 * we could find a Gather or Gather Merge node here. Note that, if
+	 * present, this will appear below nodes we considered as part of the join
+	 * strategy, but we could find another uniqueness-enforcing node below the
+	 * Gather or Gather Merge, if present.
+	 */
+	if (elidedouter == NULL)
+	{
+		elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+											  found_any_outer_gather);
+		if (found_any_outer_gather &&
+			pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+			uniqueouter = true;
+	}
+	if (elidedinner == NULL)
+	{
+		elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+											  found_any_inner_gather);
+		if (found_any_inner_gather &&
+			pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+			uniqueinner = true;
+	}
+
+	/*
+	 * It's possible that Result node has been inserted either to project a
+	 * target list or to implement a one-time filter. If so, we can descend
+	 * throught it. Note that a result node without a child would be a
+	 * degenerate scan or join, and not something we could descend through.
+	 *
+	 * XXX. I suspect it's possible for this to happen above the Gather or
+	 * Gather Merge node, too, but apparently we have no test case for that
+	 * scenario.
+	 */
+	if (elidedouter == NULL && is_result_node_with_child(outerplan))
+		elidedouter = pgpa_descend_node(pstmt, &outerplan);
+	if (elidedinner == NULL && is_result_node_with_child(innerplan))
+		elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+	/*
+	 * If this is a semijoin that was converted to an inner join by making one
+	 * side or the other unique, make a note that the inner or outer subplan,
+	 * as appropriate, should be treated as a query plan feature when the main
+	 * tree traversal reaches it.
+	 *
+	 * Conversely, if the planner could have made one side of the join unique
+	 * and thereby converted it to an inner join, and chose not to do so, that
+	 * is also worth noting.
+	 *
+	 * XXX: We admit too much non-unique advice, as in the following example
+	 * from the regression tests: EXPLAIN (PLAN_ADVICE, COSTS OFF) DELETE FROM
+	 * prt1_l WHERE EXISTS (SELECT 1 FROM int4_tbl, LATERAL (SELECT
+	 * int4_tbl.f1 FROM int8_tbl LIMIT 2) ss WHERE prt1_l.c IS NULL). We emit
+	 * SEMIJOIN_NON_UNIQUE((int4_tbl ss)) but create_unique_path() fails in
+	 * this case, so there's no sj-unique version possible.
+	 *
+	 * NB: This code could appear slightly higher up in in this function, but
+	 * none of the nodes through which we just descended should be have
+	 * associated RTIs.
+	 *
+	 * NB: This seems like a somewhat hacky way of passing information up to
+	 * the main tree walk, but I don't currently have a better idea.
+	 */
+	if (uniqueouter)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+	else if (jointype == JOIN_RIGHT_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+	if (uniqueinner)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+	else if (jointype == JOIN_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+	/* Set output parameters. */
+	*realouter = outerplan;
+	*realinner = innerplan;
+	*elidedrealouter = elidedouter;
+	*elidedrealinner = elidedinner;
+	return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+	*plan = (*plan)->lefttree;
+	return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+						bool *found_any_gather)
+{
+	if (IsA(*plan, Gather))
+	{
+		*found_any_gather = true;
+		return pgpa_descend_node(pstmt, plan);
+	}
+
+	if (IsA(*plan, GatherMerge))
+	{
+		ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+		if (elided == NULL && is_sorting_plan(*plan))
+			elided = pgpa_descend_node(pstmt, plan);
+
+		*found_any_gather = true;
+		return elided;
+	}
+
+	return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through at least one node, and
+ * otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+						ElidedNode **elided_node)
+{
+	if (*elided_node != NULL)
+		return false;
+
+	if (IsA(*plan, Agg) || IsA(*plan, Unique))
+	{
+		*elided_node = pgpa_descend_node(pstmt, plan);
+
+		if (*elided_node == NULL && is_sorting_plan(*plan))
+			*elided_node = pgpa_descend_node(pstmt, plan);
+
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+	return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+	return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
diff --git a/contrib/pg_plan_advice/pgpa_join.h b/contrib/pg_plan_advice/pgpa_join.h
new file mode 100644
index 00000000000..4dc72986a70
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.h
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+	JSTRAT_MERGE_JOIN_PLAIN = 0,
+	JSTRAT_MERGE_JOIN_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_PLAIN,
+	JSTRAT_NESTED_LOOP_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_MEMOIZE,
+	JSTRAT_HASH_JOIN
+	/* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY		((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+	Plan	   *plan;
+	ElidedNode *elided_node;
+	struct pgpa_scan *scan;
+	pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>.  When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+	/* Outermost member; must not itself be an unrolled join. */
+	pgpa_join_member outer;
+
+	/* Number of inner members. Length of the strategy and inner arrays. */
+	unsigned	ninner;
+
+	/* Array of strategies, one per non-outermost member. */
+	pgpa_join_strategy *strategy;
+
+	/* Array of members, excluding the outermost. Deepest first. */
+	pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+	return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+							 Plan *plan, bool beneath_any_gather,
+							 pgpa_join_unroller *join_unroller,
+							 pgpa_join_unroller **outer_join_unroller,
+							 pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+													pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
new file mode 100644
index 00000000000..89a675ff93e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -0,0 +1,628 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+	const char **rid_strings;
+	StringInfo	buf;
+	int			wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+									  pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+									pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+									  pgpa_scan_strategy strategy,
+									  List *scans);
+static void pgpa_output_bitmap_index_details(pgpa_output_context *context,
+											 Plan *plan);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+									  pgpa_qf_type type,
+									  List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+										char *strategy,
+										List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+								  Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+								  Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+				   pgpa_identifier *rt_identifiers)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	ListCell   *lc;
+	pgpa_output_context context;
+
+	/* Basic initialization. */
+	memset(&context, 0, sizeof(pgpa_output_context));
+	context.buf = buf;
+
+	/*
+	 * Convert identifiers to string form. Note that the loop variable here is
+	 * not an RTI, because RTIs are 1-based. Some RTIs will have no
+	 * identifier, either because the reloptkind is RTE_JOIN or because that
+	 * portion of the query didn't make it into the final plan.
+	 */
+	context.rid_strings = palloc0_array(const char *, rtable_length);
+	for (int i = 0; i < rtable_length; ++i)
+		if (rt_identifiers[i].alias_name != NULL)
+			context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+	/*
+	 * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+	 * from a psql client with default settings, psql will add one space to
+	 * the left of the output and EXPLAIN will add two more to the left of the
+	 * advice. Thus, lines of more than 77 characters will wrap. We set the
+	 * wrap limit to 76 here so that the output won't reach all the way to the
+	 * very last column of the terminal.
+	 *
+	 * Of course, this is fairly arbitrary set of assumptions, and one could
+	 * well make an argument for a different wrap limit, or for a configurable
+	 * one.
+	 */
+	context.wrap_column = 76;
+
+	/*
+	 * Each piece of JOIN_ORDER() advice fully describes the join order for a
+	 * a single unrolled join. Merging is not permitted, because that would
+	 * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+	 * scans should be used for all of those relations, and is thus equivalent
+	 * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+	 * is the driving table which is then joined to "b" then "c" then "d",
+	 * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+	 */
+	foreach(lc, walker->toplevel_unrolled_joins)
+	{
+		pgpa_unrolled_join *ujoin = lfirst(lc);
+
+		if (buf->len > 0)
+			appendStringInfoChar(buf, '\n');
+		appendStringInfo(context.buf, "JOIN_ORDER(");
+		pgpa_output_unrolled_join(&context, ujoin);
+		appendStringInfoChar(context.buf, ')');
+		pgpa_maybe_linebreak(context.buf, context.wrap_column);
+	}
+
+	/* Emit join strategy advice. */
+	for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+	{
+		char	   *strategy = pgpa_cstring_join_strategy(s);
+
+		pgpa_output_simple_strategy(&context,
+									strategy,
+									walker->join_strategies[s]);
+	}
+
+	/*
+	 * Emit scan strategy advice (but not for ordinary scans, which are
+	 * definitionally uninteresting).
+	 */
+	for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+		if (c != PGPA_SCAN_ORDINARY)
+			pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+	/* Emit query feature advice. */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+		pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+	/* Emit NO_GATHER advice. */
+	pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+						  pgpa_unrolled_join *join)
+{
+	pgpa_output_join_member(context, &join->outer);
+
+	for (int k = 0; k < join->ninner; ++k)
+	{
+		pgpa_join_member *member = &join->inner[k];
+
+		pgpa_maybe_linebreak(context->buf, context->wrap_column);
+		appendStringInfoChar(context->buf, ' ');
+		pgpa_output_join_member(context, member);
+	}
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+						pgpa_join_member *member)
+{
+	if (member->unrolled_join != NULL)
+	{
+		appendStringInfoChar(context->buf, '(');
+		pgpa_output_unrolled_join(context, member->unrolled_join);
+		appendStringInfoChar(context->buf, ')');
+	}
+	else
+	{
+		pgpa_scan  *scan = member->scan;
+
+		Assert(scan != NULL);
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '{');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, '}');
+		}
+	}
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+						  pgpa_scan_strategy strategy,
+						  List *scans)
+{
+	bool		first = true;
+
+	if (scans == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_scan_strategy(strategy));
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		Plan	   *plan = scan->plan;
+
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		/* Output the relation identifiers. */
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+
+		/* For scans involving indexes, output index information. */
+		if (strategy == PGPA_SCAN_INDEX)
+		{
+			Assert(IsA(plan, IndexScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_INDEX_ONLY)
+		{
+			Assert(IsA(plan, IndexOnlyScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context,
+									  ((IndexOnlyScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_BITMAP_HEAP)
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_bitmap_index_details(context, plan->lefttree);
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output information about which index or indexes power a BitmapHeapScan.
+ *
+ * We emit &&(i1 i2 i3) for a BitmapAnd between indexes i1, i2, and i3;
+ * and likewise ||(i1 i2 i3) for a similar BitmapOr operation.
+ */
+static void
+pgpa_output_bitmap_index_details(pgpa_output_context *context, Plan *plan)
+{
+	char	   *operator;
+	List	   *bitmapplans;
+	bool		first = true;
+
+	if (IsA(plan, BitmapIndexScan))
+	{
+		BitmapIndexScan *bitmapindexscan = (BitmapIndexScan *) plan;
+
+		pgpa_output_relation_name(context, bitmapindexscan->indexid);
+		return;
+	}
+
+	if (IsA(plan, BitmapOr))
+	{
+		operator = "||";
+		bitmapplans = ((BitmapOr *) plan)->bitmapplans;
+	}
+	else if (IsA(plan, BitmapAnd))
+	{
+		operator = "&&";
+		bitmapplans = ((BitmapAnd *) plan)->bitmapplans;
+	}
+	else
+		elog(ERROR, "unexpected node type: %d", (int) nodeTag(plan));
+
+	appendStringInfo(context->buf, "%s(", operator);
+	foreach_ptr(Plan, child_plan, bitmapplans)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+		pgpa_output_bitmap_index_details(context, child_plan);
+	}
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+	Oid			nspoid = get_rel_namespace(relid);
+	char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+	char	   *relname = get_rel_name(relid);
+
+	appendStringInfoString(context->buf, quote_identifier(relnamespace));
+	appendStringInfoChar(context->buf, '.');
+	appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+						  List *query_features)
+{
+	bool		first = true;
+
+	if (query_features == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_query_feature_type(type));
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(qf->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, qf->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, qf->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+							List *relid_sets)
+{
+	bool		first = true;
+
+	if (relid_sets == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(", strategy);
+
+	foreach_node(Bitmapset, relids, relid_sets)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+	if (relids == NULL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "NO_GATHER(");
+	pgpa_output_relations(context, context->buf, relids);
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+					  Bitmapset *relids)
+{
+	int			rti = -1;
+	bool		first = true;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		const char *rid_string = context->rid_strings[rti - 1];
+
+		if (rid_string == NULL)
+			elog(ERROR, "no identifier for RTI %d", rti);
+
+		if (first)
+		{
+			first = false;
+			appendStringInfoString(buf, rid_string);
+		}
+		else
+		{
+			pgpa_maybe_linebreak(buf, context->wrap_column);
+			appendStringInfo(buf, " %s", rid_string);
+		}
+	}
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+	switch (strategy)
+	{
+		case JSTRAT_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case JSTRAT_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case JSTRAT_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case JSTRAT_HASH_JOIN:
+			return "HASH_JOIN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+	switch (strategy)
+	{
+		case PGPA_SCAN_ORDINARY:
+			return "ORDINARY_SCAN";
+		case PGPA_SCAN_SEQ:
+			return "SEQ_SCAN";
+		case PGPA_SCAN_BITMAP_HEAP:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_SCAN_FOREIGN:
+			return "FOREIGN_JOIN";
+		case PGPA_SCAN_INDEX:
+			return "INDEX_SCAN";
+		case PGPA_SCAN_INDEX_ONLY:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_SCAN_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_SCAN_TID:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+	switch (type)
+	{
+		case PGPAQF_GATHER:
+			return "GATHER";
+		case PGPAQF_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPAQF_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPAQF_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+	}
+
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+	char	   *trailing_nl;
+	int			line_start;
+	int			save_cursor;
+
+	/* If line wrapping is disabled, exit quickly. */
+	if (wrap_column <= 0)
+		return;
+
+	/*
+	 * Set line_start to the byte offset within buf->data of the first
+	 * character of the current line, where the current line means the last
+	 * one in the buffer. Note that line_start could be the offset of the
+	 * trailing '\0' if the last character in the buffer is a line break.
+	 */
+	trailing_nl = strrchr(buf->data, '\n');
+	if (trailing_nl == NULL)
+		line_start = 0;
+	else
+		line_start = (trailing_nl - buf->data) + 1;
+
+	/*
+	 * Remember that the current end of the buffer is a potential location to
+	 * insert a line break on a future call to this function.
+	 */
+	save_cursor = buf->cursor;
+	buf->cursor = buf->len;
+
+	/* If we haven't passed the wrap column, we don't need a newline. */
+	if (buf->len - line_start <= wrap_column)
+		return;
+
+	/*
+	 * It only makes sense to insert a newline at a position later than the
+	 * beginning of the current line.
+	 */
+	if (buf->cursor <= line_start)
+		return;
+
+	/* Insert a newline at the previous cursor location. */
+	enlargeStringInfo(buf, 1);
+	memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+			buf->len - save_cursor);
+	++buf->cursor;
+	buf->data[++buf->len] = '\0';
+	buf->data[save_cursor] = '\n';
+}
diff --git a/contrib/pg_plan_advice/pgpa_output.h b/contrib/pg_plan_advice/pgpa_output.h
new file mode 100644
index 00000000000..47496d76f52
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+							   pgpa_plan_walker_context *walker,
+							   pgpa_identifier *rt_identifiers);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_parser.y b/contrib/pg_plan_advice/pgpa_parser.y
new file mode 100644
index 00000000000..4617e7f2f64
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_parser.y
@@ -0,0 +1,337 @@
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc.  This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE   pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+	char	   *str;
+	int			integer;
+	List	   *list;
+	pgpa_advice_item *item;
+	pgpa_advice_target *target;
+	pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_BITMAP TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+%token TOK_OR TOK_AND
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list bitmap_sublist bitmap_target_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> bitmap_target_item index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+		{
+			(void) yynerrs;				/* suppress compiler warning */
+			*result = $1;
+		}
+	;
+
+advice_item_list: advice_item_list advice_item
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_JOIN_ORDER;
+			$$->targets = $3;
+		}
+	| TOK_TAG_INDEX '(' index_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "index_only_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+			else if (strcmp($1, "index_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_BITMAP '(' bitmap_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+			$$->targets = $3;
+		}
+	| TOK_TAG_SIMPLE '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "no_gather") == 0)
+				$$->tag = PGPA_TAG_NO_GATHER;
+			else if (strcmp($1, "seq_scan") == 0)
+				$$->tag = PGPA_TAG_SEQ_SCAN;
+			else if (strcmp($1, "tid_scan") == 0)
+				$$->tag = PGPA_TAG_TID_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_GENERIC '(' generic_target_list ')'
+		{
+			bool	fail;
+
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = pgpa_parse_advice_tag($1, &fail);
+			if (fail)
+			{
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "unrecognized advice tag");
+			}
+
+			if ($$->tag == PGPA_TAG_FOREIGN_JOIN)
+			{
+				foreach_ptr(pgpa_advice_target, target, $3)
+				{
+					if (target->ttype == PGPA_TARGET_IDENTIFIER ||
+						list_length(target->children) == 1)
+							pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+										 "FOREIGN_JOIN targets must contain more than one relation identifier");
+				}
+			}
+
+			$$->targets = $3;
+		}
+	;
+
+relation_identifier: identifier opt_ri_occurrence opt_partition opt_plan_name
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_IDENTIFIER;
+			$$->rid.alias_name = $1;
+			$$->rid.occurrence = $2;
+			if (list_length($3) == 2)
+			{
+				$$->rid.partnsp = linitial($3);
+				$$->rid.partrel = lsecond($3);
+			}
+			else if ($3 != NIL)
+				$$->rid.partrel = linitial($3);
+			$$->rid.plan_name = $4;
+		}
+	;
+
+index_name: identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indname = $1;
+		}
+	| identifier '.' identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indnamespace = $1;
+			$$->indname = $3;
+		}
+	;
+
+opt_ri_occurrence:
+	'#' TOK_INTEGER
+		{
+			if ($2 <= 0)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "only positive occurrence numbers are permitted");
+			$$ = $2;
+		}
+	|
+		{
+			/* The default occurrence number is 1. */
+			$$ = 1;
+		}
+	;
+
+identifier: TOK_IDENT
+	| TOK_TAG_JOIN_ORDER
+	| TOK_TAG_INDEX
+	| TOK_TAG_BITMAP
+	| TOK_TAG_SIMPLE
+	| TOK_TAG_GENERIC
+	;
+
+/*
+ * When generating advice, we always schema-qualify the partition name, but
+ * when parsing advice, we accept a specification that lacks one.
+ */
+opt_partition:
+	'/' TOK_IDENT '.' TOK_IDENT
+		{ $$ = list_make2($2, $4); }
+	| '/' TOK_IDENT
+		{ $$ = list_make1($2); }
+	|
+		{ $$ = NIL; }
+	;
+
+opt_plan_name:
+	'@' TOK_IDENT
+		{ $$ = $2; }
+	|
+		{ $$ = NULL; }
+	;
+
+bitmap_target_list: bitmap_target_list relation_identifier bitmap_target_item
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+bitmap_target_item: index_name
+		{ $$ = $1; }
+	| TOK_OR '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_OR;
+			$$->children = $3;
+		}
+	| TOK_AND '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_AND;
+			$$->children = $3;
+		}
+	;
+
+bitmap_sublist: bitmap_sublist bitmap_target_item
+		{ $$ = lappend($1, $2); }
+	| bitmap_target_item
+		{ $$ = list_make1($1); }
+	;
+
+generic_target_list: generic_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| generic_target_list generic_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+generic_sublist: '(' generic_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+index_target_list:
+	  index_target_list relation_identifier index_name
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_target_list: join_order_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| join_order_target_list join_order_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_sublist:
+	'(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	| '{' simple_target_list '}'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_UNORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+simple_target_list: simple_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+%%
+
+/*
+ * Parse an advice_string and return the resulting list of pgpa_advice_item
+ * objects. If a parse error occurs, instead return NULL.
+ *
+ * If the return value is NULL, *error_p will be set to the error message;
+ * otherwise, *error_p will be set to NULL.
+ */
+List *
+pgpa_parse(const char *advice_string, char **error_p)
+{
+	yyscan_t	scanner;
+	List	   *result;
+	char	   *error = NULL;
+
+	pgpa_scanner_init(advice_string, &scanner);
+	pgpa_yyparse(&result, &error, scanner);
+	pgpa_scanner_finish(scanner);
+
+	if (error != NULL)
+	{
+		*error_p = error;
+		return NULL;
+	}
+
+	*error_p = NULL;
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
new file mode 100644
index 00000000000..767faccd8d0
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -0,0 +1,1706 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.c
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "common/hashfn_unstable.h"
+#include "nodes/makefuncs.h"
+#include "optimizer/extendplan.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/plancat.h"
+#include "optimizer/planner.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * When assertions are enabled, we try generating relation identifiers during
+ * planning, saving them in a hash table, and then cross-checking them against
+ * the ones generated after planning is complete.
+ */
+typedef struct pgpa_ri_checker_key
+{
+	char	   *plan_name;
+	Index		rti;
+} pgpa_ri_checker_key;
+
+typedef struct pgpa_ri_checker
+{
+	pgpa_ri_checker_key key;
+	uint32		status;
+	const char *rid_string;
+} pgpa_ri_checker;
+
+static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
+
+static inline bool
+pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
+{
+	if (a.rti != b.rti)
+		return false;
+	if (a.plan_name == NULL)
+		return (b.plan_name == NULL);
+	if (b.plan_name == NULL)
+		return false;
+	return strcmp(a.plan_name, b.plan_name) == 0;
+}
+
+#define SH_PREFIX			pgpa_ri_check
+#define SH_ELEMENT_TYPE		pgpa_ri_checker
+#define SH_KEY_TYPE			pgpa_ri_checker_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#endif
+
+typedef struct pgpa_planner_state
+{
+	ExplainState *explain_state;
+	pgpa_trove *trove;
+	MemoryContext trove_cxt;
+
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_check_hash *ri_check_hash;
+#endif
+} pgpa_planner_state;
+
+typedef struct pgpa_join_state
+{
+	/* Most-recently-considered outer rel. */
+	RelOptInfo *outerrel;
+
+	/* Most-recently-considered inner rel. */
+	RelOptInfo *innerrel;
+
+	/*
+	 * Array of relation identifiers for all members of this joinrel, with
+	 * outerrel idenifiers before innerrel identifiers.
+	 */
+	pgpa_identifier *rids;
+
+	/* Number of outer rel identifiers. */
+	int			outer_count;
+
+	/* Number of inner rel identifiers. */
+	int			inner_count;
+
+	/*
+	 * Trove lookup results.
+	 *
+	 * join_entries and rel_entries are arrays of entries, and join_indexes
+	 * and rel_indexes are the integer offsets within those arrays of entries
+	 * potentially relevant to us. The "join" fields correspond to a lookup
+	 * using PGPA_TROVE_LOOKUP_JOIN and the "rel" fields to a lookup using
+	 * PGPA_TROVE_LOOKUP_REL.
+	 */
+	pgpa_trove_entry *join_entries;
+	Bitmapset  *join_indexes;
+	pgpa_trove_entry *rel_entries;
+	Bitmapset  *rel_indexes;
+} pgpa_join_state;
+
+/* Saved hook values */
+static get_relation_info_hook_type prev_get_relation_info = NULL;
+static join_path_setup_hook_type prev_join_path_setup = NULL;
+static joinrel_setup_hook_type prev_joinrel_setup = NULL;
+static planner_setup_hook_type prev_planner_setup = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+
+/* Other global variabes */
+static int	planner_extension_id = -1;
+
+/* Function prototypes. */
+static void pgpa_get_relation_info(PlannerInfo *root,
+								   Oid relationObjectId,
+								   bool inhparent,
+								   RelOptInfo *rel);
+static void pgpa_joinrel_setup(PlannerInfo *root,
+							   RelOptInfo *joinrel,
+							   RelOptInfo *outerrel,
+							   RelOptInfo *innerrel,
+							   SpecialJoinInfo *sjinfo,
+							   List *restrictlist);
+static void pgpa_join_path_setup(PlannerInfo *root,
+								 RelOptInfo *joinrel,
+								 RelOptInfo *outerrel,
+								 RelOptInfo *innerrel,
+								 JoinType jointype,
+								 JoinPathExtraData *extra);
+static void pgpa_planner_setup(PlannerGlobal *glob, Query *parse,
+							   const char *query_string,
+							   double *tuple_fraction,
+							   ExplainState *es);
+static void pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string, PlannedStmt *pstmt);
+static void pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p,
+											  char *plan_name,
+											  pgpa_join_state *pjs);
+static void pgpa_planner_apply_join_path_advice(JoinType jointype,
+												uint64 *pgs_mask_p,
+												char *plan_name,
+												pgpa_join_state *pjs);
+static void pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+										   pgpa_trove_entry *scan_entries,
+										   Bitmapset *scan_indexes,
+										   pgpa_trove_entry *rel_entries,
+										   Bitmapset *rel_indexes);
+static uint64 pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag);
+static bool pgpa_join_order_permits_join(int outer_count, int inner_count,
+										 pgpa_identifier *rids,
+										 pgpa_trove_entry *entry);
+static bool pgpa_join_method_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+
+static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+										  pgpa_trove_lookup_type type,
+										  pgpa_identifier *rt_identifiers,
+										  pgpa_plan_walker_context *walker);
+
+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);
+
+/*
+ * Install planner-related hooks.
+ */
+void
+pgpa_planner_install_hooks(void)
+{
+	planner_extension_id = GetPlannerExtensionId("pg_plan_advice");
+	prev_get_relation_info = get_relation_info_hook;
+	get_relation_info_hook = pgpa_get_relation_info;
+	prev_joinrel_setup = joinrel_setup_hook;
+	joinrel_setup_hook = pgpa_joinrel_setup;
+	prev_join_path_setup = join_path_setup_hook;
+	join_path_setup_hook = pgpa_join_path_setup;
+	prev_planner_setup = planner_setup_hook;
+	planner_setup_hook = pgpa_planner_setup;
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgpa_planner_shutdown;
+}
+
+/*
+ * Hook function for get_relation_info().
+ *
+ * We can apply scan advice at this opint, and we also usee this as an
+ * opportunity to do range-table identifier cross-checking in assert-enabled
+ * builds.
+ *
+ * XXX: We currently emit useless advice like NO_GATHER("*RESULT*") for trivial
+ * queries. The advice is useless because get_relation_info isn't called for
+ * non-relation RTEs. We should either suppress the advice in such cases, or
+ * add a hook that can apply it.
+ */
+static void
+pgpa_get_relation_info(PlannerInfo *root, Oid relationObjectId,
+					   bool inhparent, RelOptInfo *rel)
+{
+	pgpa_planner_state *pps;
+
+	/* 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. */
+	if (pps != NULL)
+		pgpa_ri_checker_save(pps, root, rel);
+
+	/* If query advice was provided, search for relevant entries. */
+	if (pps != NULL && pps->trove != NULL)
+	{
+		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,
+						  &tresult_scan);
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+						  &tresult_rel);
+
+		/* If relevant entries were found, apply them. */
+		if (tresult_scan.indexes != NULL || tresult_rel.indexes != NULL)
+			pgpa_planner_apply_scan_advice(rel,
+										   tresult_scan.entries,
+										   tresult_scan.indexes,
+										   tresult_rel.entries,
+										   tresult_rel.indexes);
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_get_relation_info)
+		(*prev_get_relation_info) (root, relationObjectId, inhparent, rel);
+}
+
+/*
+ * Search for advice pertaining to a proposed join.
+ */
+static pgpa_join_state *
+pgpa_get_join_state(PlannerInfo *root, RelOptInfo *joinrel,
+					RelOptInfo *outerrel, RelOptInfo *innerrel)
+{
+	pgpa_planner_state *pps;
+	pgpa_join_state *pjs;
+	bool		new_pjs = false;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+	if (pps == NULL || pps->trove == NULL)
+	{
+		/* No advice applies to this query, hence none to this joinrel. */
+		return NULL;
+	}
+
+	/*
+	 * See whether we've previously associated a pgpa_join_state with this
+	 * joinrel. If we have not, we need to try to construct one. If we have,
+	 * then there are two cases: (a) if innerrel and outerrel are unchanged,
+	 * we can simply use it, and (b) if they have changed, we need to rejigger
+	 * the array of identifiers but can still skip the trove lookup.
+	 */
+	pjs = GetRelOptInfoExtensionState(joinrel, planner_extension_id);
+	if (pjs != NULL)
+	{
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+		{
+			/*
+			 * If there's no potentially relevant advice, then the presence of
+			 * this pgpa_join_state acts like a negative cache entry: it tells
+			 * us not to bother searching the trove for advice, because we
+			 * will not find any.
+			 */
+			return NULL;
+		}
+
+		if (pjs->outerrel == outerrel && pjs->innerrel == innerrel)
+		{
+			/* No updates required, so just return. */
+			/* XXX. Does this need to do something different under GEQO? */
+			return pjs;
+		}
+	}
+
+	/*
+	 * If there's no pgpa_join_state yet, we need to allocate one. Trove keys
+	 * will not get built for RTE_JOIN RTEs, so the array may end up being
+	 * larger than needed. It's not worth trying to compute a perfectly
+	 * accurate count here.
+	 */
+	if (pjs == NULL)
+	{
+		int			pessimistic_count = bms_num_members(joinrel->relids);
+
+		pjs = palloc0_object(pgpa_join_state);
+		pjs->rids = palloc_array(pgpa_identifier, pessimistic_count);
+		new_pjs = true;
+	}
+
+	/*
+	 * Either we just allocated a new pgpa_join_state, or the existing one
+	 * needs reconfiguring for a new innerrel and outerrel. The required array
+	 * size can't change, so we can overwrite the existing one.
+	 */
+	pjs->outerrel = outerrel;
+	pjs->innerrel = innerrel;
+	pjs->outer_count =
+		pgpa_compute_identifiers_by_relids(root, outerrel->relids, pjs->rids);
+	pjs->inner_count =
+		pgpa_compute_identifiers_by_relids(root, innerrel->relids,
+										   pjs->rids + pjs->outer_count);
+
+	/*
+	 * If we allocated a new pgpa_join_state, search our trove of advice for
+	 * relevant entries. The trove lookup will return the same results for
+	 * every outerrel/innerrel combination, so we don't need to repeat that
+	 * work every time.
+	 */
+	if (new_pjs)
+	{
+		pgpa_trove_result tresult;
+
+		/* Find join entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_JOIN,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->join_entries = tresult.entries;
+		pjs->join_indexes = tresult.indexes;
+
+		/* Find rel entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->rel_entries = tresult.entries;
+		pjs->rel_indexes = tresult.indexes;
+
+		/* Now that the new pgpa_join_state is fully valid, save a pointer. */
+		SetRelOptInfoExtensionState(joinrel, planner_extension_id, pjs);
+
+		/*
+		 * If there was no relevant advice found, just return NULL. This
+		 * pgpa_join_state will stick around as a sort of negative cache
+		 * entry, so that future calls for this same joinrel quickly return
+		 * NULL.
+		 */
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+			return NULL;
+	}
+
+	return pjs;
+}
+
+/*
+ * Enforce any provided advice that is relevant to any method of implementing
+ * this join.
+ *
+ * Although we're passed the outerrel and innerrel here, those are just
+ * whatever values happened to prompt the creation of this joinrel; they
+ * shouldn't really influence our choice of what advice to apply.
+ */
+static void
+pgpa_joinrel_setup(PlannerInfo *root, RelOptInfo *joinrel,
+				   RelOptInfo *outerrel, RelOptInfo *innerrel,
+				   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_joinrel_advice(&joinrel->pgs_mask,
+										  root->plan_name,
+										  pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_joinrel_setup)
+		(*prev_joinrel_setup) (root, joinrel, outerrel, innerrel,
+							   sjinfo, restrictlist);
+}
+
+/*
+ * Enforce any provided advice that is relevant to this particular method of
+ * implementing this particular join.
+ */
+static void
+pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
+					 RelOptInfo *outerrel, RelOptInfo *innerrel,
+					 JoinType jointype, JoinPathExtraData *extra)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_join_path_advice(jointype,
+											&extra->pgs_mask,
+											root->plan_name,
+											pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_join_path_setup)
+		(*prev_join_path_setup) (root, joinrel, outerrel, innerrel,
+								 jointype, extra);
+}
+
+/*
+ * Prepare advice for use by a query.
+ */
+static void
+pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
+				   double *tuple_fraction, ExplainState *es)
+{
+	pgpa_trove *trove = NULL;
+	pgpa_planner_state *pps;
+	char	   *error;
+	bool		needs_pps = false;
+
+	/*
+	 * If any advice was provided, build a trove of advice for use during
+	 * planning.
+	 */
+	if (pg_plan_advice_advice != NULL && pg_plan_advice_advice[0] != '\0')
+	{
+		List	   *advice_items;
+
+		/*
+		 * Parsing shouldn't fail here, because we must have previously parsed
+		 * successfully in pg_plan_advice_advice_check_hook, but if it does,
+		 * emit a warning.
+		 */
+		advice_items = pgpa_parse(pg_plan_advice_advice, &error);
+		if (error)
+			elog(WARNING, "could not parse advice: %s", error);
+
+		/*
+		 * It's possible that the advice string was non-empty but contained no
+		 * actual advice, e.g. it was all whitespace.
+		 */
+		if (advice_items != NIL)
+		{
+			trove = pgpa_build_trove(advice_items);
+			needs_pps = true;
+		}
+	}
+
+#ifdef USE_ASSERT_CHECKING
+
+	/*
+	 * If asserts are enabled, always build a private state object for
+	 * cross-checks.
+	 */
+	needs_pps = true;
+#endif
+
+	/* Initialize and store private state, if required. */
+	if (needs_pps)
+	{
+		pps = palloc0_object(pgpa_planner_state);
+		pps->explain_state = es;
+		pps->trove = trove;
+#ifdef USE_ASSERT_CHECKING
+		pps->ri_check_hash =
+			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
+#endif
+		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
+	}
+}
+
+/*
+ * Carry out whatever work we want to do after planning is complete.
+ */
+static void
+pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	pgpa_planner_state *pps;
+	pgpa_trove *trove = NULL;
+	ExplainState *es = NULL;
+	pgpa_plan_walker_context walker = {0};	/* placate compiler */
+	bool		do_advice_feedback;
+	bool		do_collect_advice;
+	List	   *pgpa_items = NIL;
+	pgpa_identifier *rt_identifiers = NULL;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
+	if (pps != NULL)
+	{
+		trove = pps->trove;
+		es = pps->explain_state;
+	}
+
+	/* If at least one collector is enabled, generate advice. */
+	do_collect_advice = (pg_plan_advice_local_collection_limit > 0 ||
+						 pg_plan_advice_shared_collection_limit > 0);
+
+	/* If we applied advice, generate feedback. */
+	do_advice_feedback = (trove != NULL && es != NULL);
+
+	/* If either of the above apply, analyze the resulting PlannedStmt. */
+	if (do_collect_advice || do_advice_feedback)
+	{
+		pgpa_plan_walker(&walker, pstmt);
+		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+	}
+
+	/*
+	 * If advice collection is enabled, put the advice in string form and send
+	 * it to the collector.
+	 */
+	if (do_collect_advice)
+	{
+		char	   *advice_string;
+		StringInfoData buf;
+
+		/* Generate a textual advice string. */
+		initStringInfo(&buf);
+		pgpa_output_advice(&buf, &walker, rt_identifiers);
+		advice_string = buf.data;
+
+		/* If the advice string is empty, don't bother collecting it. */
+		if (advice_string[0] != '\0')
+			pgpa_collect_advice(pstmt->queryId, query_string, advice_string);
+
+		/*
+		 * If we've gone to the trouble of generating an advice string, and if
+		 * we're inside EXPLAIN, save the string so we don't need to
+		 * regenerate it.
+		 */
+		if (es != NULL)
+			pgpa_items = lappend(pgpa_items,
+								 makeDefElem("advice_string",
+											 (Node *) makeString(advice_string),
+											 -1));
+	}
+
+	/*
+	 * If we are planning within EXPLAIN, make arrangements to allow EXPLAIN
+	 * to tell the user what has happened with the provided advice.
+	 *
+	 * NB: If EXPLAIN is used on a prepared is a prepared statement, planning
+	 * will have already happened happened without recording these details. We
+	 * could consider adding a GUC to cater to that scenario; or we could do
+	 * this work all the time, but that seems like too much overhead.
+	 */
+	if (do_advice_feedback)
+	{
+		List	   *feedback = NIL;
+
+		/*
+		 * Inject a Node-tree representation of all the trove-entry flags into
+		 * the PlannedStmt.
+		 */
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_SCAN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_JOIN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_REL,
+												rt_identifiers, &walker);
+
+		pgpa_items = lappend(pgpa_items, makeDefElem("feedback",
+													 (Node *) feedback,
+													 -1));
+	}
+
+	/* Push whatever data we're saving into the PlannedStmt. */
+	if (pgpa_items != NIL)
+		pstmt->extension_state =
+			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);
+}
+
+/*
+ * Enforce overall restrictions on a join relation that apply uniformly
+ * regardless of the choice of inner and outer rel.
+ */
+static void
+pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
+								  pgpa_join_state *pjs)
+{
+	int			i = -1;
+	int			flags;
+	bool		gather_conflict = false;
+	uint64		gather_mask = 0;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	bool		partitionwise_conflict = false;
+	int			partitionwise_outcome = 0;
+	Bitmapset  *partitionwise_partial_match = NULL;
+	Bitmapset  *partitionwise_full_match = NULL;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type itm;
+		bool		full_match = false;
+		uint64		my_gather_mask = 0;
+		int			my_partitionwise_outcome = 0;	/* >0 yes, <0 no */
+
+		/*
+		 * For GATHER and GATHER_MERGE, if the specified relations exactly
+		 * match this joinrel, do whatever the advice says; otherwise, don't
+		 * allow Gather or Gather Merge at this level. For NO_GATHER, there
+		 * must be a single target relation which must be included in this
+		 * joinrel, so just don't allow Gather or Gather Merge here, full
+		 * stop.
+		 */
+		if (entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			full_match = true;
+		}
+		else
+		{
+			int			total_count;
+
+			total_count = pjs->outer_count + pjs->inner_count;
+			itm = pgpa_identifiers_match_target(total_count, pjs->rids,
+												entry->target);
+			Assert(itm != PGPA_ITM_DISJOINT);
+
+			if (itm == PGPA_ITM_EQUAL)
+			{
+				full_match = true;
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+					my_partitionwise_outcome = 1;
+				else if (entry->tag == PGPA_TAG_GATHER)
+					my_gather_mask = PGS_GATHER;
+				else if (entry->tag == PGPA_TAG_GATHER_MERGE)
+					my_gather_mask = PGS_GATHER_MERGE;
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+			else
+			{
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else if (entry->tag == PGPA_TAG_GATHER ||
+						 entry->tag == PGPA_TAG_GATHER_MERGE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (full_match)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+
+		/*
+		 * Likewise, if we set my_partitionwise_outcome up above, then we (1)
+		 * make a note if the advice conflicted, (2) remember what the desired
+		 * outcome was, and (3) remember whether this was a full or partial
+		 * match.
+		 */
+		if (my_partitionwise_outcome != 0)
+		{
+			if (partitionwise_outcome != 0 &&
+				partitionwise_outcome != my_partitionwise_outcome)
+				partitionwise_conflict = true;
+			partitionwise_outcome = my_partitionwise_outcome;
+			if (full_match)
+				partitionwise_full_match =
+					bms_add_member(partitionwise_full_match, i);
+			else
+				partitionwise_partial_match =
+					bms_add_member(partitionwise_partial_match, i);
+		}
+	}
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched, and if
+	 * the set of targets exactly matched this relation, fully matched. If
+	 * there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
+
+	/* Likewise for partitionwise advice. */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (partitionwise_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		*pgs_mask_p &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		*pgs_mask_p |= gather_mask;
+	}
+
+	/*
+	 * If there is a non-conflicting partitionwise specification, enforce.
+	 *
+	 * To force a partitionwise join, we disable all the ordinary means of
+	 * performing a join, and instead only Append and MergeAppend paths here.
+	 * To prevent one, we just disable Append and MergeAppend.  Note that we
+	 * must not unset PGS_CONSIDER_PARTITIONWISE even when we don't want a
+	 * partitionwise join here, because we might want one at a higher level
+	 * that is constructing using paths from this level.
+	 */
+	if (partitionwise_outcome != 0 && !partitionwise_conflict)
+	{
+		if (partitionwise_outcome > 0)
+			*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) |
+				PGS_APPEND | PGS_MERGE_APPEND | PGS_CONSIDER_PARTITIONWISE;
+		else
+			*pgs_mask_p &= ~(PGS_APPEND | PGS_MERGE_APPEND);
+	}
+}
+
+/*
+ * Enforce restrictions on the join order or join method.
+ *
+ * Note that, although it is possible to view PARTITIONWISE advice as
+ * controlling the join method, we can't enforce it here, because the code
+ * path where this executes only deals with join paths that are built directly
+ * from a single outer path and a single inner path.
+ */
+static void
+pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
+									char *plan_name,
+									pgpa_join_state *pjs)
+{
+	int			i = -1;
+	Bitmapset  *jo_permit_indexes = NULL;
+	Bitmapset  *jo_deny_indexes = NULL;
+	Bitmapset  *jm_indexes = NULL;
+	bool		jm_conflict = false;
+	uint32		join_mask = 0;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->join_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->join_entries[i];
+		uint32		my_join_mask;
+
+		/* Handle join order advice. */
+		if (entry->tag == PGPA_TAG_JOIN_ORDER)
+		{
+			if (pgpa_join_order_permits_join(pjs->outer_count,
+											 pjs->inner_count,
+											 pjs->rids,
+											 entry))
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			else
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			continue;
+		}
+
+		/* Handle join strategy advice. */
+		my_join_mask = pgpa_join_strategy_mask_from_advice_tag(entry->tag);
+		if (my_join_mask != 0)
+		{
+			bool		permit;
+			bool		restrict_method;
+
+			if (entry->tag == PGPA_TAG_FOREIGN_JOIN)
+				permit = pgpa_opaque_join_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			else
+				permit = pgpa_join_method_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			if (!permit)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				jm_indexes = bms_add_member(jo_permit_indexes, i);
+				if (join_mask != 0 && join_mask != my_join_mask)
+					jm_conflict = true;
+				join_mask = my_join_mask;
+			}
+			continue;
+		}
+
+		/* Handle semijoin uniqueness advice. */
+		if (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE ||
+			entry->tag == PGPA_TAG_SEMIJOIN_NON_UNIQUE)
+		{
+			bool		advice_unique;
+			bool		jt_unique;
+			bool		jt_non_unique;
+			bool		restrict_method;
+
+			/* Advice wants to unique-ify and use a regular join? */
+			advice_unique = (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE);
+
+			/* Planner is trying to unique-ify and use a regular join? */
+			jt_unique = (jointype == JOIN_UNIQUE_INNER ||
+						 jointype == JOIN_UNIQUE_OUTER);
+
+			/* Planner is trying a semi-join, without unique-ifying? */
+			jt_non_unique = (jointype == JOIN_SEMI ||
+							 jointype == JOIN_RIGHT_SEMI);
+
+			/*
+			 * These advice tags behave very much like join method advice, in
+			 * that they want the inner side of the semijoin to match the
+			 * relations listed in the advice. Hence, we test whether join
+			 * method advice would enforce a join order restriction here, and
+			 * disallow the join if not.
+			 *
+			 * XXX. Think harder about right semijoins.
+			 */
+			if (!pgpa_join_method_permits_join(pjs->outer_count,
+											   pjs->inner_count,
+											   pjs->rids,
+											   entry,
+											   &restrict_method))
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				if (!jt_unique && !jt_non_unique)
+				{
+					/*
+					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
+					 * or SJ_NON_UNIQUE can be applied.
+					 */
+					entry->flags |= PGPA_TE_INAPPLICABLE;
+				}
+				else if (advice_unique != jt_unique)
+					jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			}
+			continue;
+		}
+	}
+
+	/*
+	 * If the advice indicates both that this join order is permissible and
+	 * also that it isn't, then mark advice related to the join order as
+	 * conflicting.
+	 */
+	if (jo_permit_indexes != NULL && jo_deny_indexes != NULL)
+	{
+		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * If more than one join method specification is relevant here and they
+	 * differ, mark them all as conflicting.
+	 */
+	if (jm_conflict)
+		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
+							 PGPA_TE_CONFLICTING);
+
+	/*
+	 * If we were advised to deny this join order, then do so. However, if we
+	 * were also advised to permit it, then do nothing, since the advice
+	 * conflicts.
+	 */
+	if (jo_deny_indexes != NULL && jo_permit_indexes == NULL)
+		*pgs_mask_p = 0;
+
+	/*
+	 * If we were advised to restrict the join method, then do so. However, if
+	 * we got conflicting join method advice or were also advised to reject
+	 * this join order completely, then instead do nothing.
+	 */
+	if (join_mask != 0 && !jm_conflict && jo_deny_indexes == NULL)
+		*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) | join_mask;
+}
+
+/*
+ * Translate an advice tag into a path generation strategy mask.
+ *
+ * This function can be called with tag types that don't represent join
+ * strategies. In such cases, we just return 0, which can't be confused with
+ * a valid mask.
+ */
+static uint64
+pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag)
+{
+	switch (tag)
+	{
+		case PGPA_TAG_FOREIGN_JOIN:
+			return PGS_FOREIGNJOIN;
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return PGS_MERGEJOIN_PLAIN;
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return PGS_MERGEJOIN_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return PGS_NESTLOOP_PLAIN;
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return PGS_NESTLOOP_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return PGS_NESTLOOP_MEMOIZE;
+		case PGPA_TAG_HASH_JOIN:
+			return PGS_HASHJOIN;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Does a certain item of join order advice permit a certain join?
+ */
+static bool
+pgpa_join_order_permits_join(int outer_count, int inner_count,
+							 pgpa_identifier *rids,
+							 pgpa_trove_entry *entry)
+{
+	bool		loop = true;
+	bool		sublist = false;
+	int			length;
+	int			outer_length;
+	pgpa_advice_target *target = entry->target;
+	pgpa_advice_target *prefix_target;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * Find the innermost sublist that contains all keys; if no sublist does,
+	 * then continue processing with the toplevel list.
+	 *
+	 * For example, if the advice says JOIN_ORDER(t1 t2 (t3 t4 t5)), then we
+	 * should evaluate joins that only involve t3, t4, and/or t5 against the
+	 * (t3 t4 t5) sublist, and others against the full list.
+	 *
+	 * Note that (1) outermost sublist is always ordered and (2) whenever we
+	 * zoom into an unordered sublist, we instantly accept the proposed join.
+	 * If the advice says JOIN_ORDER(t1 t2 {t3 t4 t5}), any approach to
+	 * joining t3, t4, and/or t5 is acceptable.
+	 */
+	while (loop)
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+		loop = false;
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_itm_type itm;
+
+			if (child_target->ttype == PGPA_TARGET_IDENTIFIER)
+				continue;
+
+			itm = pgpa_identifiers_match_target(outer_count + inner_count,
+												rids, child_target);
+			if (itm == PGPA_ITM_EQUAL || itm == PGPA_ITM_KEYS_ARE_SUBSET)
+			{
+				if (child_target->ttype == PGPA_TARGET_ORDERED_LIST)
+				{
+					target = child_target;
+					sublist = true;
+					loop = true;
+					break;
+				}
+				else
+				{
+					Assert(child_target->ttype == PGPA_TARGET_UNORDERED_LIST);
+					return true;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Try to find a prefix of the selected join order list that is exactly
+	 * equal to the outer side of the proposed join.
+	 */
+	length = list_length(target->children);
+	prefix_target = palloc0_object(pgpa_advice_target);
+	prefix_target->ttype = PGPA_TARGET_ORDERED_LIST;
+	for (outer_length = 1; outer_length <= length; ++outer_length)
+	{
+		pgpa_itm_type itm;
+
+		/* Avoid leaking memory in every loop iteration. */
+		if (prefix_target->children != NULL)
+			list_free(prefix_target->children);
+		prefix_target->children = list_copy_head(target->children,
+												 outer_length);
+
+		/* Search, hoping to find an exact match. */
+		itm = pgpa_identifiers_match_target(outer_count, rids, prefix_target);
+		if (itm == PGPA_ITM_EQUAL)
+			break;
+
+		/*
+		 * If the prefix of the join order list that we're considering
+		 * includes some but not all of the outer rels, we can make the prefix
+		 * longer to find an exact match. But the advice hasn't mentioned
+		 * everything that's part of our outer rel yet, but has mentioned
+		 * things that are not, then this join doesn't match the join order
+		 * list.
+		 */
+		if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+			return false;
+	}
+
+	/*
+	 * If the previous looped stopped before the prefix_target included the
+	 * entire join order list, then the next member of the join order list
+	 * must exactly match the inner side of the join.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), if the outer side of the
+	 * current join includes only t1, then the inner side must be exactly t2;
+	 * if the outer side includes both t1 and t2, then the inner side must
+	 * include exactly t3, t4, and t5.
+	 */
+	if (outer_length < length)
+	{
+		pgpa_advice_target *inner_target;
+		pgpa_itm_type itm;
+
+		inner_target = list_nth(target->children, outer_length);
+
+		itm = pgpa_identifiers_match_target(inner_count, rids + outer_count,
+											inner_target);
+
+		/*
+		 * Before returning, consider whether we need to mark this entry as
+		 * fully matched. If we found every item but one on the lefthand side
+		 * of the join and the last item on the righthand side of the join,
+		 * then the answer is yes.
+		 */
+		if (outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
+			entry->flags |= PGPA_TE_MATCH_FULL;
+
+		return (itm == PGPA_ITM_EQUAL);
+	}
+
+	/*
+	 * If we get here, then the outer side of the join includes the entirety
+	 * of the join order list. In this case, we behave differently depending
+	 * on whether we're looking at the top-level join order list or sublist.
+	 * At the top-level, we treat the specified list as mandating that the
+	 * actual join order has the given list as a prefix, but a sublist
+	 * requires an exact match.
+	 *
+	 * Exmaple: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), we must start by joining
+	 * all five of those relations and in that sequence, but once that is
+	 * done, it's OK to join any other rels that are part of the join problem.
+	 * This allows a user to specify the driving table and perhaps the first
+	 * few things to which it should be joined while leaving the rest of the
+	 * join order up the optimizer. But it seems like it would be surprising,
+	 * given that specification, if the user could add t6 to the (t3 t4 t5)
+	 * sub-join, so we don't allow that. If we did want to allow it, the logic
+	 * earlier in this function would require substantial adjustment: we could
+	 * allow the t3-t4-t5-t6 join to be built here, but the next step of
+	 * joining t1-t2 to the result would still be rejected.
+	 */
+	return !sublist;
+}
+
+/*
+ * Does a certain item of join method advice permit a certain join?
+ *
+ * Advice such as HASH_JOIN((x y)) means that there should be a hash join with
+ * exactly x and y on the inner side. Obviously, this means that if we are
+ * considering a join with exactly x and y on the inner side, we should enforce
+ * the use of a hash join. However, it also means that we must reject some
+ * incompatible join orders entirely.  For example, a join with exactly x
+ * and y on the outer side shouldn't be allowed, because such paths might win
+ * over the advice-driven path on cost.
+ *
+ * To accommodate these requirements, this function returns true if the join
+ * should be allowed and false if it should not. Furthermore, *restrict_method
+ * is set to true if the join method should be enforced and false if not.
+ */
+static bool
+pgpa_join_method_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	/*
+	 * If our inner rel mentions exactly the same relations as the advice
+	 * target, allow the join and enforce the join method restriction.
+	 *
+	 * If our inner rel mentions a superset of the target relations, allow the
+	 * join. The join we care about has already taken place, and this advice
+	 * imposes no further restrictions.
+	 */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If our outer rel mentions a supserset of the relations in the advice
+	 * target, no restrictions apply. The join we care has already taken
+	 * place, and this advice imposes no further restrictions.
+	 *
+	 * On the other hand, if our outer rel mentions exactly the relations
+	 * mentioned in the advice target, the planner is trying to reverse the
+	 * sides of the join as compared with our desired outcome. Reject that.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+	else if (outer_itm == PGPA_ITM_EQUAL)
+		return false;
+
+	/*
+	 * If the advice target mentions only a single relation, the test below
+	 * cannot ever pass, so save some work by exiting now.
+	 */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+		return false;
+
+	/*
+	 * If everything in the joinrel is appears in the advice target, we're
+	 * below the level of the join we want to control.
+	 *
+	 * For example, HASH_JOIN((x y)) doesn't restrict how x and y can be
+	 * joined.
+	 *
+	 * This lookup shouldn't return PGPA_ITM_DISJOINT, because any such advice
+	 * should not have been returned from the trove in the first place.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've already permitted all allowable cases, so reject this.
+	 *
+	 * If we reach this point, then the advice overlaps with this join but
+	 * isn't entirely contained within either side, and there's also at least
+	 * one relation present in the join that isn't mentioned by the advice.
+	 *
+	 * For instance, in the HASH_JOIN((x y)) example, we would reach here if x
+	 * were on one side of the join, y on the other, and at least one of the
+	 * two sides also included some other relation, say t. In that case,
+	 * accepting this join would allow the (x y t) joinrel to contain
+	 * non-disabled paths that do not put (x y) on the inner side of a hash
+	 * join; we could instead end up with something like (x JOIN t) JOIN y.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning an opaque join permit a certain join?
+ *
+ * By an opaque join, we mean one where the exact mechanism by which the
+ * join is performed is not visible to PostgreSQL. Currently this is the
+ * case only for foreign joins: FOREIGN_JOIN((x y z)) means that x, y, and
+ * z are joined on the remote side, but we know nothing about the join order
+ * or join methods used over there.
+ */
+static bool
+pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	if (join_itm == PGPA_ITM_EQUAL)
+	{
+		/*
+		 * We have an exact match, and should therefore allow the join and
+		 * enforce the use of the relevant opaque join method.
+		 */
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+	{
+		/*
+		 * If join_itm == PGPA_ITM_TARGETS_ARE_SUBSET, then the join we care
+		 * about has already taken place and no further restrictions apply.
+		 *
+		 * If join_itm == PGPA_ITM_KEYS_ARE_SUBSET, we're still building up to
+		 * the join we care about and have not introduced any extraneous
+		 * relations not named in the advice. Note that ForeignScan paths for
+		 * joins are built up from ForeignScan paths from underlying joins and
+		 * scans, so we must not disable this join when considering a subset
+		 * of the relations we ultimately want.
+		 */
+		return true;
+	}
+
+	/*
+	 * The advice overlaps the join, but at least one relation is present in
+	 * the join that isn't mentioned by the advice. We want to disable such
+	 * paths so that we actually push down the join as intended.
+	 */
+	return false;
+}
+
+/*
+ * Apply scan advice to a RelOptInfo.
+ *
+ * XXX. For bitmap heap scans, we're just ignoring the index information from
+ * the advice. That's not cool.
+ */
+static void
+pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+							   pgpa_trove_entry *scan_entries,
+							   Bitmapset *scan_indexes,
+							   pgpa_trove_entry *rel_entries,
+							   Bitmapset *rel_indexes)
+{
+	bool		gather_conflict = false;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	int			i = -1;
+	pgpa_trove_entry *scan_entry = NULL;
+	int			flags;
+	bool		scan_type_conflict = false;
+	Bitmapset  *scan_type_indexes = NULL;
+	Bitmapset  *scan_type_rel_indexes = NULL;
+	uint64		gather_mask = 0;
+	uint64		scan_type = 0;
+
+	/* 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;
+
+		/* Translate our advice tags to a scan strategy advice value. */
+		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+			my_scan_type = PGS_BITMAPSCAN;
+		else if (my_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN)
+			my_scan_type = PGS_INDEXONLYSCAN | PGS_CONSIDER_INDEXONLY;
+		else if (my_entry->tag == PGPA_TAG_INDEX_SCAN)
+			my_scan_type = PGS_INDEXSCAN;
+		else if (my_entry->tag == PGPA_TAG_SEQ_SCAN)
+			my_scan_type = PGS_SEQSCAN;
+		else if (my_entry->tag == PGPA_TAG_TID_SCAN)
+			my_scan_type = PGS_TIDSCAN;
+
+		/*
+		 * If this is understandable scan advice, hang on to the entry, the
+		 * inferred scan type type, and the index at which we found it.
+		 *
+		 * Also make a note if we see conflicting scan type advice. Note that
+		 * we regard two index specifications as conflicting unless they match
+		 * exactly. In theory, perhaps we could regard INDEX_SCAN(a c) and
+		 * 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 (scan_type != 0 && scan_type != my_scan_type)
+				scan_type_conflict = true;
+			if (!scan_type_conflict && scan_entry != NULL &&
+				my_entry->target->itarget != NULL &&
+				scan_entry->target->itarget != NULL &&
+				!pgpa_index_targets_equal(scan_entry->target->itarget,
+										  my_entry->target->itarget))
+				scan_type_conflict = true;
+			scan_entry = my_entry;
+			scan_type = my_scan_type;
+			scan_type_indexes = bms_add_member(scan_type_indexes, i);
+		}
+	}
+
+	/* Scrutinize available gather-related and partitionwise advice. */
+	i = -1;
+	while ((i = bms_next_member(rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *my_entry = &rel_entries[i];
+		uint64		my_gather_mask = 0;
+		bool		just_one_rel;
+
+		just_one_rel = my_entry->target->ttype == PGPA_TARGET_IDENTIFIER
+			|| list_length(my_entry->target->children) == 1;
+
+		/*
+		 * PARTITIONWISE behaves like a scan type, except that if there's more
+		 * than one relation targeted, it has no effect at this level.
+		 */
+		if (my_entry->tag == PGPA_TAG_PARTITIONWISE)
+		{
+			if (just_one_rel)
+			{
+				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
+
+				if (scan_type != 0 && scan_type != my_scan_type)
+					scan_type_conflict = true;
+				scan_entry = my_entry;
+				scan_type = my_scan_type;
+				scan_type_rel_indexes =
+					bms_add_member(scan_type_rel_indexes, i);
+			}
+			continue;
+		}
+
+		/*
+		 * GATHER and GATHER_MERGE applied to a single rel mean that we should
+		 * use the correspondings strategy here, while applying either to more
+		 * than one rel means we should not use those strategies here, but
+		 * rather at the level of the joinrel that corresponds to what was
+		 * specified. NO_GATHER can only be applied to single rels.
+		 *
+		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
+		 * equivalent to allowing the non-use of either form of Gather here.
+		 */
+		if (my_entry->tag == PGPA_TAG_GATHER ||
+			my_entry->tag == PGPA_TAG_GATHER_MERGE)
+		{
+			if (!just_one_rel)
+				my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			else if (my_entry->tag == PGPA_TAG_GATHER)
+				my_gather_mask = PGS_GATHER;
+			else
+				my_gather_mask = PGS_GATHER_MERGE;
+		}
+		else if (my_entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			Assert(just_one_rel);
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (just_one_rel)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+	}
+
+	/* Enforce choice of index. */
+	if (scan_entry != NULL && !scan_type_conflict &&
+		(scan_entry->tag == PGPA_TAG_INDEX_SCAN ||
+		 scan_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN))
+	{
+		pgpa_index_target *itarget = scan_entry->target->itarget;
+		IndexOptInfo *matched_index = NULL;
+
+		Assert(itarget->itype == PGPA_INDEX_NAME);
+
+		foreach_node(IndexOptInfo, index, rel->indexlist)
+		{
+			char	   *relname = get_rel_name(index->indexoid);
+			Oid			nspoid = get_rel_namespace(index->indexoid);
+			char	   *relnamespace = get_namespace_name(nspoid);
+
+			if (strcmp(itarget->indname, relname) == 0 &&
+				(itarget->indnamespace == NULL ||
+				 strcmp(itarget->indnamespace, relnamespace) == 0))
+			{
+				matched_index = index;
+				break;
+			}
+		}
+
+		if (matched_index == NULL)
+		{
+			/* Don't force the scan type if the index doesn't exist. */
+			scan_type = 0;
+
+			/* Mark advice as inapplicable. */
+			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
+								 PGPA_TE_INAPPLICABLE);
+		}
+		else
+		{
+			/* Retain this index and discard the rest. */
+			rel->indexlist = list_make1(matched_index);
+		}
+	}
+
+	/*
+	 * Mark all the scan method entries as fully matched; and if they specify
+	 * different things, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	if (scan_type_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
+	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched. Mark
+	 * the ones that included this relation as a target by itself as fully
+	 * matched. If there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
+
+	/* If there is a non-conflicting scan specification, enforce it. */
+	if (scan_type != 0 && !scan_type_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
+			  PGS_CONSIDER_INDEXONLY);
+		rel->pgs_mask |= scan_type;
+	}
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		rel->pgs_mask |= gather_mask;
+	}
+}
+
+/*
+ * Add feedback entries to for one trove slice to the provided list and
+ * return the resulting list.
+ *
+ * Feedback entries are generated from the trove entry's flags. It's assumed
+ * that the caller has already set all relevant flags with the exception of
+ * PGPA_TE_FAILED. We set that flag here if appropriate.
+ */
+static List *
+pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+							 pgpa_trove_lookup_type type,
+							 pgpa_identifier *rt_identifiers,
+							 pgpa_plan_walker_context *walker)
+{
+	pgpa_trove_entry *entries;
+	int			nentries;
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	pgpa_trove_lookup_all(trove, type, &entries, &nentries);
+	for (int i = 0; i < nentries; ++i)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+		DefElem    *item;
+
+		/*
+		 * If this entry was fully matched, check whether generating advice
+		 * from this plan would produce such an entry. If not, label the entry
+		 * as failed.
+		 */
+		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+			!pgpa_walker_would_advise(walker, rt_identifiers,
+									  entry->tag, entry->target))
+			entry->flags |= PGPA_TE_FAILED;
+
+		item = makeDefElem(pgpa_cstring_trove_entry(entry),
+						   (Node *) makeInteger(entry->flags), -1);
+		list = lappend(list, item);
+	}
+
+	return list;
+}
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * Fast hash function for a key consisting of an RTI and plan name.
+ */
+static uint32
+pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	hs.accum = key.rti;
+	fasthash_combine(&hs);
+
+	/* plan_name can be NULL */
+	if (key.plan_name == NULL)
+		sp_len = 0;
+	else
+		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+
+	/* hashfn_unstable.h recommends using string length as tweak */
+	return fasthash_final32(&hs, sp_len);
+}
+
+#endif
+
+/*
+ * Save the range table identifier for one relation for future cross-checking.
+ */
+static void
+pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
+					 RelOptInfo *rel)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_checker_key key;
+	pgpa_ri_checker *check;
+	pgpa_identifier rid;
+	const char *rid_string;
+	bool		found;
+
+	key.rti = bms_singleton_member(rel->relids);
+	key.plan_name = root->plan_name;
+	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
+	rid_string = pgpa_identifier_string(&rid);
+	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
+	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
+	check->rid_string = rid_string;
+#endif
+}
+
+/*
+ * 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_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	pgpa_ri_check_iterator it;
+	pgpa_ri_checker *check;
+
+	/* 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. */
+	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
+	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	{
+		int			rtoffset = 0;
+		const char *rid_string;
+		Index		flat_rti;
+
+		/*
+		 * 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.
+		 */
+		if (check->key.plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				/*
+				 * If rtinfo->dummy is set, then the subquery's range table
+				 * will only have been partially copied to the final range
+				 * table. Specifically, only RTE_RELATION entries and
+				 * 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
+				 * in the final rtable.
+				 *
+				 * With more complex logic, we could work around that problem
+				 * by remembering the whole contents of the subquery's rtable
+				 * during planning, determining which of those would have been
+				 * copied to the final rtable, and matching them up. But it
+				 * doesn't seem like a worthwhile endeavor for right now,
+				 * because RTIs from such subqueries won't appear in the plan
+				 * tree itself, just in the range table. Hence, we can neither
+				 * generate nor accept advice for them.
+				 */
+				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+					&& !rtinfo->dummy)
+				{
+					rtoffset = rtinfo->rtoffset;
+					Assert(rtoffset > 0);
+					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;
+		}
+
+		/*
+		 * check->key.rti is the RTI that we saw prior to range-table
+		 * flattening, so we must add the appropriate RT offset to get the
+		 * final RTI.
+		 */
+		flat_rti = check->key.rti + rtoffset;
+		Assert(flat_rti <= list_length(pstmt->rtable));
+
+		/* Assert that the string we compute now matches the previous one. */
+		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
+		Assert(strcmp(rid_string, check->rid_string) == 0);
+	}
+#endif
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
new file mode 100644
index 00000000000..7d40b910b00
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -0,0 +1,17 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.h
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_PLANNER_H
+#define PGPA_PLANNER_H
+
+extern void pgpa_planner_install_hooks(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
new file mode 100644
index 00000000000..dbd7c99e4c2
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -0,0 +1,278 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.c
+ *	  analysis of scans in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+
+static pgpa_scan *pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								 pgpa_scan_strategy strategy,
+								 Bitmapset *relids,
+								 bool beneath_any_gather);
+
+
+static Bitmapset *filter_out_join_relids(Bitmapset *relids, List *rtable);
+static RTEKind unique_nonjoin_rtekind(Bitmapset *relids, List *rtable);
+
+/*
+ * Build a pgpa_scan object for a Plan node and update the plan walker
+ * context as appopriate.  If this is an Append or MergeAppend scan, also
+ * build pgpa_scan for any scans that were consolidated into this one by
+ * Append/MergeAppend pull-up.
+ *
+ * If there is at least one ElidedNode for this plan node, pass the uppermost
+ * one as elided_node, else pass NULL.
+ *
+ * Set the 'beneath_any_gather' node if we are underneath a Gather or
+ * Gather Merge node.
+ *
+ * Set the 'within_join_problem' flag if we're inside of a join problem and
+ * not otherwise.
+ */
+pgpa_scan *
+pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+				ElidedNode *elided_node,
+				bool beneath_any_gather, bool within_join_problem)
+{
+	pgpa_scan_strategy strategy = PGPA_SCAN_ORDINARY;
+	Bitmapset  *relids = NULL;
+	int			rti = -1;
+	List	   *child_append_relid_sets = NIL;
+
+	if (elided_node != NULL)
+	{
+		NodeTag		elided_type = elided_node->elided_type;
+
+		/*
+		 * If setrefs processing elided an Append or MergeAppend node that had
+		 * only one surviving child, then this is a partitionwise "scan" --
+		 * which may really be a partitionwise join, but there's no need to
+		 * distinguish.
+		 *
+		 * If it's a trivial SubqueryScan that was elided, then this is an
+		 * "ordinary" scan i.e. one for which we need to generate advice
+		 * because the planner has not made any meaningful choice.
+		 */
+		relids = elided_node->relids;
+		if (elided_type == T_Append || elided_type == T_MergeAppend)
+			strategy = PGPA_SCAN_PARTITIONWISE;
+		else
+			strategy = PGPA_SCAN_ORDINARY;
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+	{
+		relids = bms_make_singleton(rti);
+
+		switch (nodeTag(plan))
+		{
+			case T_SeqScan:
+				strategy = PGPA_SCAN_SEQ;
+				break;
+			case T_BitmapHeapScan:
+				strategy = PGPA_SCAN_BITMAP_HEAP;
+				break;
+			case T_IndexScan:
+				strategy = PGPA_SCAN_INDEX;
+				break;
+			case T_IndexOnlyScan:
+				strategy = PGPA_SCAN_INDEX_ONLY;
+				break;
+			case T_TidScan:
+			case T_TidRangeScan:
+				strategy = PGPA_SCAN_TID;
+				break;
+			default:
+
+				/*
+				 * This case includes a ForeignScan targeting a single
+				 * relation; no other strategy is possible in that case, but
+				 * see below, where things are different in multi-relation
+				 * cases.
+				 */
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+	}
+	else if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		switch (nodeTag(plan))
+		{
+			case T_ForeignScan:
+
+				/*
+				 * If multiple relations are being targeted by a single
+				 * foreign scan, then the foreign join has been pushed to the
+				 * remote side, and we want that to be reflected in the
+				 * generated advice.
+				 */
+				strategy = PGPA_SCAN_FOREIGN;
+				break;
+			case T_Append:
+
+				/*
+				 * Append nodes can represent partitionwise scans of a a
+				 * relation, but when they implement a set operation, they are
+				 * just ordinary scans.
+				 */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((Append *) plan)->child_append_relid_sets;
+				break;
+			case T_MergeAppend:
+				/* Some logic here as for Append, above. */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((MergeAppend *) plan)->child_append_relid_sets;
+				break;
+			default:
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+
+	/*
+	 * If this is an Append or MergeAppend node into which subordinate Append
+	 * or MergeAppend paths were merged, each of those merged paths is
+	 * effectively another scan for which we need to account.
+	 */
+	foreach_node(Bitmapset, child_relids, child_append_relid_sets)
+	{
+		Bitmapset  *child_nonjoin_relids;
+
+		child_nonjoin_relids = filter_out_join_relids(child_relids,
+													  walker->pstmt->rtable);
+		(void) pgpa_make_scan(walker, plan, strategy,
+							  child_nonjoin_relids,
+							  beneath_any_gather);
+	}
+
+	/*
+	 * If this plan node has no associated RTIs, it's not a scan. When the
+	 * 'within_join_problem' flag is set, that's unexpected, so throw an
+	 * error, else return quietly.
+	 */
+	if (relids == NULL)
+	{
+		if (within_join_problem)
+			elog(ERROR, "plan node has no RTIs: %d", (int) nodeTag(plan));
+		return NULL;
+	}
+
+	return pgpa_make_scan(walker, plan, strategy, relids, beneath_any_gather);
+}
+
+/*
+ * Create a single pgpa_scan object and update the pgpa_plan_walker_context.
+ */
+static pgpa_scan *
+pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+			   pgpa_scan_strategy strategy, Bitmapset *relids,
+			   bool beneath_any_gather)
+{
+	pgpa_scan  *scan;
+
+	/* Create the scan object. */
+	scan = palloc(sizeof(pgpa_scan));
+	scan->plan = plan;
+	scan->strategy = strategy;
+	scan->relids = relids;
+	scan->beneath_any_gather = beneath_any_gather;
+
+	/* Add it to the appropriate list. */
+	walker->scans[scan->strategy] = lappend(walker->scans[scan->strategy],
+											scan);
+
+	/*
+	 * We intend to emit NO_GATHER() advice for each scan that doesn't appear
+	 * beneath a Gather or Gather Merge node, but we need not do this for
+	 * partitionwise scans, because emitting NO_GATHER() for the child scans
+	 * suffices.
+	 */
+	if (!scan->beneath_any_gather && scan->strategy != PGPA_SCAN_PARTITIONWISE)
+		walker->no_gather_scans = bms_add_members(walker->no_gather_scans,
+												  scan->relids);
+
+	return scan;
+}
+
+/*
+ * Determine the unique rtekind of a set of relids.
+ */
+static RTEKind
+unique_nonjoin_rtekind(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	bool		first = true;
+	RTEKind		rtekind;
+
+	Assert(relids != NULL);
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		if (first)
+		{
+			rtekind = rte->rtekind;
+			first = false;
+		}
+		else if (rtekind != rte->rtekind)
+			elog(ERROR, "rtekind mismatch: %d vs. %d",
+				 rtekind, rte->rtekind);
+	}
+
+	if (first)
+		elog(ERROR, "no non-RTE_JOIN RTEs found");
+
+	return rtekind;
+}
+
+/*
+ * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
+ */
+static Bitmapset *
+filter_out_join_relids(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	Bitmapset  *result = NULL;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind != RTE_JOIN)
+			result = bms_add_member(result, rti);
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_scan.h b/contrib/pg_plan_advice/pgpa_scan.h
new file mode 100644
index 00000000000..90a08b41c5b
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.h
@@ -0,0 +1,86 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.h
+ *	  analysis of scans in Plan trees
+ *
+ * For purposes of this module, a "scan" includes (1) single plan nodes that
+ * scan multiple RTIs, such as a degenerate Result node that replaces what
+ * would otherwise have been a join, and (2) Append and MergeAppend nodes
+ * implementing a partitionwise scan or a partitionwise join. Said
+ * differently, scans are the leaves of the join tree for a single join
+ * problem.
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_SCAN_H
+#define PGPA_SCAN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+
+/*
+ * Scan strategies.
+ *
+ * PGPA_SCAN_ORDINARY is any scan strategy that isn't interesting to us
+ * because there is no meaningful planner decision involved. For example,
+ * the only way to scan a subquery is a SubqueryScan, and the only way to
+ * scan a VALUES construct is a ValuesScan. We need not care exactly which
+ * type of planner node was used in such cases, because the same thing will
+ * happen when replanning.
+ *
+ * PGPA_SCAN_ORDINARY also includes Result nodes that correspond to scans
+ * or even joins that are proved empty. We don't know whether or not the scan
+ * or join will still be provably empty at replanning time, but if it is,
+ * then no scan-type advice is needed, and if it's not, we can't recommend
+ * a scan type based on the current plan.
+ *
+ * PGPA_SCAN_PARTITIONWISE also lumps together scans and joins: this can
+ * be either a partitionwise scan of a partitioned table or a partitionwise
+ * join between several partitioned tables. Note that all decisions about
+ * whether or not to use partitionwise join are meaningful: no matter what
+ * we decided this time, we could do more or fewer things partitionwise the
+ * next time.
+ *
+ * PGPA_SCAN_FOREIGN is only used when there's more than one relation involved;
+ * a single-table foreign scan is classified as ordinary, since there is no
+ * decision to make in that case.
+ *
+ * Other scan strategies map one-to-one to plan nodes.
+ */
+typedef enum
+{
+	PGPA_SCAN_ORDINARY = 0,
+	PGPA_SCAN_SEQ,
+	PGPA_SCAN_BITMAP_HEAP,
+	PGPA_SCAN_FOREIGN,
+	PGPA_SCAN_INDEX,
+	PGPA_SCAN_INDEX_ONLY,
+	PGPA_SCAN_PARTITIONWISE,
+	PGPA_SCAN_TID
+	/* update NUM_PGPA_SCAN_STRATEGY if you add anything here */
+} pgpa_scan_strategy;
+
+#define NUM_PGPA_SCAN_STRATEGY	((int) PGPA_SCAN_TID + 1)
+
+/*
+ * All of the details we need regarding a scan.
+ */
+typedef struct pgpa_scan
+{
+	Plan	   *plan;
+	pgpa_scan_strategy strategy;
+	Bitmapset  *relids;
+	bool		beneath_any_gather;
+} pgpa_scan;
+
+extern pgpa_scan *pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								  ElidedNode *elided_node,
+								  bool beneath_any_gather,
+								  bool within_join_problem);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scanner.l b/contrib/pg_plan_advice/pgpa_scanner.l
new file mode 100644
index 00000000000..be7d7ba13a6
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scanner.l
@@ -0,0 +1,299 @@
+%top{
+/*
+ * Scanner for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_scanner.l
+ */
+#include "postgres.h"
+
+#include "common/string.h"
+#include "nodes/miscnodes.h"
+#include "parser/scansup.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Extra data that we pass around when during scanning.
+ *
+ * 'litbuf' is used to implement the <xd> exclusive state, which handles
+ * double-quoted identifiers.
+ */
+typedef struct pgpa_yy_extra_type
+{
+	StringInfoData	litbuf;
+} pgpa_yy_extra_type;
+
+}
+
+%{
+/* LCOV_EXCL_START */
+
+#define YY_DECL \
+	extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result, \
+						  char **parse_error_msg_p, yyscan_t yyscanner)
+
+/* No reason to constrain amount of data slurped */
+#define YY_READ_BUF_SIZE 16777216
+
+/* Avoid exit() on fatal scanner errors (a bit ugly -- see yy_fatal_error) */
+#undef fprintf
+#define fprintf(file, fmt, msg)  fprintf_to_ereport(fmt, msg)
+
+static void
+fprintf_to_ereport(const char *fmt, const char *msg)
+{
+	ereport(ERROR, (errmsg_internal("%s", msg)));
+}
+%}
+
+%option reentrant
+%option bison-bridge
+%option 8bit
+%option never-interactive
+%option nodefault
+%option noinput
+%option nounput
+%option noyywrap
+%option noyyalloc
+%option noyyrealloc
+%option noyyfree
+%option warn
+%option prefix="pgpa_yy"
+%option extra-type="pgpa_yy_extra_type *"
+
+/*
+ * What follows is a severely stripped-down version of the core scanner. We
+ * only care about recognizing identifiers with or without identifier quoting
+ * (i.e. double-quoting), decimal integers, and a small handful of other
+ * things. Keep these rules in sync with src/backend/parser/scan.l. As in that
+ * file, we use an exclusive state called 'xc' for C-style comments, and an
+ * exclusive state called 'xd' for double-quoted identifiers.
+ */
+%x xc
+%x xd
+
+ident_start		[A-Za-z\200-\377_]
+ident_cont		[A-Za-z\200-\377_0-9\$]
+
+identifier		{ident_start}{ident_cont}*
+
+decdigit		[0-9]
+decinteger		{decdigit}(_?{decdigit})*
+
+space			[ \t\n\r\f\v]
+whitespace		{space}+
+
+dquote			\"
+xdstart			{dquote}
+xdstop			{dquote}
+xddouble		{dquote}{dquote}
+xdinside		[^"]+
+
+xcstart			\/\*
+xcstop			\*+\/
+xcinside		[^*/]+
+
+%%
+
+{whitespace}	{ /* ignore */ }
+
+{identifier}	{
+					char   *str;
+					bool	fail;
+					pgpa_advice_tag_type	tag;
+
+					/*
+					 * Unlike the core scanner, we don't truncate identifiers
+					 * here. There is no obvious reason to do so.
+					 */
+					str = downcase_identifier(yytext, yyleng, false, false);
+					yylval->str = str;
+
+					/*
+					 * If it's not a tag, just return TOK_IDENT; else, return
+					 * a token type based on how further parsing should
+					 * proceed.
+					 */
+					tag = pgpa_parse_advice_tag(str, &fail);
+					if (fail)
+						return TOK_IDENT;
+					else if (tag == PGPA_TAG_JOIN_ORDER)
+						return TOK_TAG_JOIN_ORDER;
+					else if (tag == PGPA_TAG_INDEX_SCAN ||
+							 tag == PGPA_TAG_INDEX_ONLY_SCAN)
+						return TOK_TAG_INDEX;
+					else if (tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+						return TOK_TAG_BITMAP;
+					else if (tag == PGPA_TAG_SEQ_SCAN ||
+							 tag == PGPA_TAG_TID_SCAN ||
+							 tag == PGPA_TAG_NO_GATHER)
+						return TOK_TAG_SIMPLE;
+					else
+						return TOK_TAG_GENERIC;
+				}
+
+{decinteger}	{
+					char   *endptr;
+
+					errno = 0;
+					yylval->integer = strtoint(yytext, &endptr, 10);
+					if (*endptr != '\0' || errno == ERANGE)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "integer out of range");
+					return TOK_INTEGER;
+				}
+
+{xcstart}		{
+					BEGIN(xc);
+				}
+
+{xdstart}		{
+					BEGIN(xd);
+					resetStringInfo(&yyextra->litbuf);
+				}
+
+"||"			{ return TOK_OR; }
+
+"&&"			{ return TOK_AND; }
+
+.				{ return yytext[0]; }
+
+<xc>{xcstop}	{
+					BEGIN(INITIAL);
+				}
+
+<xc>{xcinside}	{
+					/* discard multiple characters without slash or asterisk */
+				}
+
+<xc>.			{
+					/*
+					 * Discard any single character. flex prefers longer
+					 * matches, so this rule will never be picked when we could
+					 * have matched xcstop.
+					 *
+					 * NB: At present, we don't bother to support nested
+					 * C-style comments here, but this logic could be extended
+					 * if that restriction poses a problem.
+					 */
+				}
+
+<xc><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated comment");
+				}
+
+<xd>{xdstop}	{
+					BEGIN(INITIAL);
+					yylval->str = pstrdup(yyextra->litbuf.data);
+					return TOK_IDENT;
+				}
+
+<xd>{xddouble}	{
+					appendStringInfoChar(&yyextra->litbuf, '"');
+				}
+
+<xd>{xdinside}	{
+					appendBinaryStringInfo(&yyextra->litbuf, yytext, yyleng);
+				}
+
+<xd><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated quoted identifier");
+				}
+
+%%
+
+/* LCOV_EXCL_STOP */
+
+/*
+ * Handler for errors while scanning or parsing advice.
+ *
+ * bison passes the error message to us via 'message', and the context is
+ * available via the 'yytext' macro. We assemble those values into a final
+ * error text and then arrange to pass it back to the caller of pgpa_yyparse()
+ * by storing it into *parse_error_msg_p.
+ */
+void
+pgpa_yyerror(List **result, char **parse_error_msg_p, yyscan_t yyscanner,
+			 const char *message)
+{
+	struct yyguts_t *yyg = (struct yyguts_t *) yyscanner;	/* needed for yytext
+															 * macro */
+
+
+	/* report only the first error in a parse operation */
+	if (*parse_error_msg_p)
+		return;
+
+	if (yytext[0])
+		*parse_error_msg_p = psprintf("%s at or near \"%s\"", message, yytext);
+	else
+		*parse_error_msg_p = psprintf("%s at end of input", message);
+}
+
+/*
+ * Initialize the advice scanner.
+ *
+ * This should be called before parsing begins.
+ */
+void
+pgpa_scanner_init(const char *str, yyscan_t *yyscannerp)
+{
+	yyscan_t	yyscanner;
+	pgpa_yy_extra_type	*yyext = palloc0_object(pgpa_yy_extra_type);
+
+	if (yylex_init(yyscannerp) != 0)
+		elog(ERROR, "yylex_init() failed: %m");
+
+	yyscanner = *yyscannerp;
+
+	initStringInfo(&yyext->litbuf);
+	pgpa_yyset_extra(yyext, yyscanner);
+
+	yy_scan_string(str, yyscanner);
+}
+
+
+/*
+ * Shut down the advice scanner.
+ *
+ * This should be called after parsing is complete.
+ */
+void
+pgpa_scanner_finish(yyscan_t yyscanner)
+{
+	yylex_destroy(yyscanner);
+}
+
+/*
+ * Interface functions to make flex use palloc() instead of malloc().
+ * It'd be better to make these static, but flex insists otherwise.
+ */
+
+void *
+yyalloc(yy_size_t size, yyscan_t yyscanner)
+{
+	return palloc(size);
+}
+
+void *
+yyrealloc(void *ptr, yy_size_t size, yyscan_t yyscanner)
+{
+	if (ptr)
+		return repalloc(ptr, size);
+	else
+		return palloc(size);
+}
+
+void
+yyfree(void *ptr, yyscan_t yyscanner)
+{
+	if (ptr)
+		pfree(ptr);
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
new file mode 100644
index 00000000000..a92121feb1d
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -0,0 +1,490 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.c
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * This name comes from the English expression "trove of advice", which
+ * means a collection of wisdom. This slightly unusual term is chosen to
+ * avoid naming confusion; for example, "collection of advice" would
+ * invite confusion with pgpa_collector.c. Note that, while we don't know
+ * whether the provided advice is actually wise, it's not our job to
+ * question the user's choices.
+ *
+ * The goal of this module is to make it easy to locate the specific
+ * bits of advice that pertain to any given part of a query, or to
+ * determine that there are none.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_trove.h"
+
+#include "common/hashfn_unstable.h"
+
+/*
+ * An advice trove is organized into a series of "slices", each of which
+ * contains information about one topic e.g. scan methods. Each slice consists
+ * of an array of trove entries plus a hash table that we can use to determine
+ * which ones are relevant to a particular part of the query.
+ */
+typedef struct pgpa_trove_slice
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	pgpa_trove_entry *entries;
+	struct pgpa_trove_entry_hash *hash;
+} pgpa_trove_slice;
+
+/*
+ * Scan advice is stored into 'scan'; join advice is stored into 'join'; and
+ * advice that can apply to both cases is stored into 'rel'. This lets callers
+ * ask just for what's relevant. These slices correspond to the possible values
+ * of pgpa_trove_lookup_type.
+ */
+struct pgpa_trove
+{
+	pgpa_trove_slice join;
+	pgpa_trove_slice rel;
+	pgpa_trove_slice scan;
+};
+
+/*
+ * We're going to build a hash table to allow clients of this module to find
+ * relevant advice for a given part of the query quickly. However, we're going
+ * to use only three of the five key fields as hash keys. There are two reasons
+ * for this.
+ *
+ * First, it's allowable to set partition_schema to NULL to match a partition
+ * with the correct name in any schema.
+ *
+ * Second, we expect the "occurrence" and "partition_schema" portions of the
+ * relation identifiers to be mostly uninteresting. Most of the time, the
+ * occurrence field will be 1 and the partition_schema values will all be the
+ * same. Even when there is some variation, the absolute number of entries
+ * that have the same values for all three of these key fields should be
+ * quite small.
+ */
+typedef struct
+{
+	const char *alias_name;
+	const char *partition_name;
+	const char *plan_name;
+} pgpa_trove_entry_key;
+
+typedef struct
+{
+	pgpa_trove_entry_key key;
+	int			status;
+	Bitmapset  *indexes;
+} pgpa_trove_entry_element;
+
+static uint32 pgpa_trove_entry_hash_key(pgpa_trove_entry_key key);
+
+static inline bool
+pgpa_trove_entry_compare_key(pgpa_trove_entry_key a, pgpa_trove_entry_key b)
+{
+	if (strcmp(a.alias_name, b.alias_name) != 0)
+		return false;
+
+	if (!strings_equal_or_both_null(a.partition_name, b.partition_name))
+		return false;
+
+	if (!strings_equal_or_both_null(a.plan_name, b.plan_name))
+		return false;
+
+	return true;
+}
+
+#define SH_PREFIX			pgpa_trove_entry
+#define SH_ELEMENT_TYPE		pgpa_trove_entry_element
+#define SH_KEY_TYPE			pgpa_trove_entry_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_trove_entry_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_trove_entry_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static void pgpa_init_trove_slice(pgpa_trove_slice *tslice);
+static void pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+									pgpa_advice_tag_type tag,
+									pgpa_advice_target *target);
+static void pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash,
+								   pgpa_advice_target *target,
+								   int index);
+static Bitmapset *pgpa_trove_slice_lookup(pgpa_trove_slice *tslice,
+										  pgpa_identifier *rid);
+
+/*
+ * Build a trove of advice from a list of advice items.
+ *
+ * Caller can obtain a list of advice items to pass to this function by
+ * calling pgpa_parse().
+ */
+pgpa_trove *
+pgpa_build_trove(List *advice_items)
+{
+	pgpa_trove *trove = palloc_object(pgpa_trove);
+
+	pgpa_init_trove_slice(&trove->join);
+	pgpa_init_trove_slice(&trove->rel);
+	pgpa_init_trove_slice(&trove->scan);
+
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		switch (item->tag)
+		{
+			case PGPA_TAG_JOIN_ORDER:
+				{
+					pgpa_advice_target *target;
+
+					/*
+					 * For most advice types, each element in the top-level
+					 * list is a separate target, but it's most convenient to
+					 * regard the entirety of a JOIN_ORDER specification as a
+					 * single target. Since it wasn't represented that way
+					 * during parsing, build a surrogate object now.
+					 */
+					target = palloc0_object(pgpa_advice_target);
+					target->ttype = PGPA_TARGET_ORDERED_LIST;
+					target->children = item->targets;
+
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_INDEX_ONLY_SCAN:
+			case PGPA_TAG_INDEX_SCAN:
+			case PGPA_TAG_SEQ_SCAN:
+			case PGPA_TAG_TID_SCAN:
+
+				/*
+				 * Scan advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					/*
+					 * For now, all of our scan types target single relations,
+					 * but in the future this might not be true, e.g. a custom
+					 * scan could replace a join.
+					 */
+					Assert(target->ttype == PGPA_TARGET_IDENTIFIER);
+					pgpa_trove_add_to_slice(&trove->scan,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_FOREIGN_JOIN:
+			case PGPA_TAG_HASH_JOIN:
+			case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			case PGPA_TAG_MERGE_JOIN_PLAIN:
+			case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			case PGPA_TAG_NESTED_LOOP_PLAIN:
+			case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			case PGPA_TAG_SEMIJOIN_UNIQUE:
+
+				/*
+				 * Join strategy advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_PARTITIONWISE:
+			case PGPA_TAG_GATHER:
+			case PGPA_TAG_GATHER_MERGE:
+			case PGPA_TAG_NO_GATHER:
+
+				/*
+				 * Advice about a RelOptInfo relevant to both scans and joins.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->rel,
+											item->tag, target);
+				}
+				break;
+		}
+	}
+
+	return trove;
+}
+
+/*
+ * Search a trove of advice for relevant entries.
+ *
+ * All parameters are input parameters except for *result, which is an output
+ * parameter used to return results to the caller.
+ */
+void
+pgpa_trove_lookup(pgpa_trove *trove, pgpa_trove_lookup_type type,
+				  int nrids, pgpa_identifier *rids, pgpa_trove_result *result)
+{
+	pgpa_trove_slice *tslice;
+	Bitmapset  *indexes;
+
+	Assert(nrids > 0);
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	indexes = pgpa_trove_slice_lookup(tslice, &rids[0]);
+	for (int i = 1; i < nrids; ++i)
+	{
+		Bitmapset  *other_indexes;
+
+		/*
+		 * If the caller is asking about two relations that aren't part of the
+		 * same subquery, they've messed up.
+		 */
+		Assert(strings_equal_or_both_null(rids[0].plan_name,
+										  rids[i].plan_name));
+
+		other_indexes = pgpa_trove_slice_lookup(tslice, &rids[i]);
+		indexes = bms_union(indexes, other_indexes);
+	}
+
+	result->entries = tslice->entries;
+	result->indexes = indexes;
+}
+
+/*
+ * Return all entries in a trove slice to the caller.
+ *
+ * The first two arguments are input arguments, and the remainder are output
+ * arguments.
+ */
+void
+pgpa_trove_lookup_all(pgpa_trove *trove, pgpa_trove_lookup_type type,
+					  pgpa_trove_entry **entries, int *nentries)
+{
+	pgpa_trove_slice *tslice;
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	*entries = tslice->entries;
+	*nentries = tslice->nused;
+}
+
+/*
+ * Convert a trove entry to an item of plan advice that would produce it.
+ */
+char *
+pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	appendStringInfo(&buf, "%s", pgpa_cstring_advice_tag(entry->tag));
+
+	/* JOIN_ORDER tags are transformed by pgpa_build_trove; undo that here */
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, '(');
+	else
+		Assert(entry->target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	pgpa_format_advice_target(&buf, entry->target);
+
+	if (entry->target->itarget != NULL)
+	{
+		appendStringInfoChar(&buf, ' ');
+		pgpa_format_index_target(&buf, entry->target->itarget);
+	}
+
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, ')');
+
+	return buf.data;
+}
+
+/*
+ * Set PGPA_TE_* flags on a set of trove entries.
+ */
+void
+pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
+{
+	int			i = -1;
+
+	while ((i = bms_next_member(indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+
+		entry->flags |= flags;
+	}
+}
+
+/*
+ * Add a new advice target to an existing pgpa_trove_slice object.
+ */
+static void
+pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+						pgpa_advice_tag_type tag,
+						pgpa_advice_target *target)
+{
+	pgpa_trove_entry *entry;
+
+	if (tslice->nused >= tslice->nallocated)
+	{
+		int			new_allocated;
+
+		new_allocated = tslice->nallocated * 2;
+		tslice->entries = repalloc_array(tslice->entries, pgpa_trove_entry,
+										 new_allocated);
+		tslice->nallocated = new_allocated;
+	}
+
+	entry = &tslice->entries[tslice->nused];
+	entry->tag = tag;
+	entry->target = target;
+	entry->flags = 0;
+
+	pgpa_trove_add_to_hash(tslice->hash, target, tslice->nused);
+
+	tslice->nused++;
+}
+
+/*
+ * Update the hash table for a newly-added advice target.
+ */
+static void
+pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash, pgpa_advice_target *target,
+					   int index)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	bool		found;
+
+	/* For non-identifiers, add entries for all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_trove_add_to_hash(hash, child_target, index);
+		}
+		return;
+	}
+
+	/* Sanity checks. */
+	Assert(target->rid.occurrence > 0);
+	Assert(target->rid.alias_name != NULL);
+
+	/* Add an entry for this relation identifier. */
+	key.alias_name = target->rid.alias_name;
+	key.partition_name = target->rid.partrel;
+	key.plan_name = target->rid.plan_name;
+	element = pgpa_trove_entry_insert(hash, key, &found);
+	element->indexes = bms_add_member(element->indexes, index);
+}
+
+/*
+ * Create and initialize a new pgpa_trove_slice object.
+ */
+static void
+pgpa_init_trove_slice(pgpa_trove_slice *tslice)
+{
+	/*
+	 * In an ideal world, we'll make tslice->nallocated big enough that the
+	 * array and hash table will be large enough to contain the number of
+	 * advice items in this trove slice, but a generous default value is not
+	 * good for performance, because pgpa_init_trove_slice() has to zero an
+	 * amount of memory proportional to tslice->nallocated. Hence, we keep the
+	 * starting value quite small, on the theory that advice strings will
+	 * often be relatively short.
+	 */
+	tslice->nallocated = 16;
+	tslice->nused = 0;
+	tslice->entries = palloc_array(pgpa_trove_entry, tslice->nallocated);
+	tslice->hash = pgpa_trove_entry_create(CurrentMemoryContext,
+										   tslice->nallocated, NULL);
+}
+
+/*
+ * Fast hash function for a key consisting of alias_name, partition_name,
+ * and plan_name.
+ */
+static uint32
+pgpa_trove_entry_hash_key(pgpa_trove_entry_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	/* alias_name may not be NULL */
+	sp_len = fasthash_accum_cstring(&hs, key.alias_name);
+
+	/* partition_name and plan_name, however, can be NULL */
+	if (key.partition_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.partition_name);
+	if (key.plan_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.plan_name);
+
+	/*
+	 * hashfn_unstable.h recommends using string length as tweak. It's not
+	 * clear to me what to do if there are multiple strings, so for now I'm
+	 * just using the total of all of the lengths.
+	 */
+	return fasthash_final32(&hs, sp_len);
+}
+
+/*
+ * Look for matching entries.
+ */
+static Bitmapset *
+pgpa_trove_slice_lookup(pgpa_trove_slice *tslice, pgpa_identifier *rid)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	Bitmapset  *result = NULL;
+
+	Assert(rid->occurrence >= 1);
+
+	key.alias_name = rid->alias_name;
+	key.partition_name = rid->partrel;
+	key.plan_name = rid->plan_name;
+
+	element = pgpa_trove_entry_lookup(tslice->hash, key);
+
+	if (element != NULL)
+	{
+		int			i = -1;
+
+		while ((i = bms_next_member(element->indexes, i)) >= 0)
+		{
+			pgpa_trove_entry *entry = &tslice->entries[i];
+
+			/*
+			 * We know that this target or one of its descendents matches the
+			 * identifier on the three key fields above, but we don't know
+			 * which descendent or whether the occurence and schema also
+			 * match.
+			 */
+			if (pgpa_identifier_matches_target(rid, entry->target))
+				result = bms_add_member(result, i);
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
new file mode 100644
index 00000000000..479c3f75778
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -0,0 +1,113 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.h
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_TROVE_H
+#define PGPA_TROVE_H
+
+#include "pgpa_ast.h"
+
+#include "nodes/bitmapset.h"
+
+typedef struct pgpa_trove pgpa_trove;
+
+/*
+ * Flags that can be set on a pgpa_trove_entry to indicate what happened when
+ * trying to plan using advice.
+ *
+ * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_TE_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_TE_MATCH_PARTIAL		0x0001
+#define PGPA_TE_MATCH_FULL			0x0002
+#define PGPA_TE_INAPPLICABLE		0x0004
+#define PGPA_TE_CONFLICTING			0x0008
+#define PGPA_TE_FAILED				0x0010
+
+/*
+ * Each entry in a trove of advice represents the application of a tag to
+ * a single target.
+ */
+typedef struct pgpa_trove_entry
+{
+	pgpa_advice_tag_type tag;
+	pgpa_advice_target *target;
+	int			flags;
+} pgpa_trove_entry;
+
+/*
+ * What kind of information does the caller want to find in a trove?
+ *
+ * PGPA_TROVE_LOOKUP_SCAN means we're looking for scan advice.
+ *
+ * PGPA_TROVE_LOOKUP_JOIN means we're looking for join-related advice.
+ * This includes join order advice, join method advice, and semijoin-uniqueness
+ * advice.
+ *
+ * PGPA_TROVE_LOOKUP_REL means we're looking for general advice about this
+ * a RelOptInfo that may correspond to either a scan or a join. This includes
+ * gather-related advice and partitionwise advice. Note that partitionwise
+ * advice might seem like join advice, but that's not a helpful way of viewing
+ * the matter because (1) partitionwise advice is also relevant at the scan
+ * level and (2) other types of join advice affect only what to do from
+ * join_path_setup_hook, but partitionwise advice affects what to do in
+ * joinrel_setup_hook.
+ */
+typedef enum pgpa_trove_lookup_type
+{
+	PGPA_TROVE_LOOKUP_JOIN,
+	PGPA_TROVE_LOOKUP_REL,
+	PGPA_TROVE_LOOKUP_SCAN
+} pgpa_trove_lookup_type;
+
+/*
+ * This struct is used to store the result of a trove lookup. For each member
+ * of "indexes", the entry at the corresponding offset within "entries" is one
+ * of the results.
+ */
+typedef struct pgpa_trove_result
+{
+	pgpa_trove_entry *entries;
+	Bitmapset  *indexes;
+} pgpa_trove_result;
+
+extern pgpa_trove *pgpa_build_trove(List *advice_items);
+extern void pgpa_trove_lookup(pgpa_trove *trove,
+							  pgpa_trove_lookup_type type,
+							  int nrids,
+							  pgpa_identifier *rids,
+							  pgpa_trove_result *result);
+extern void pgpa_trove_lookup_all(pgpa_trove *trove,
+								  pgpa_trove_lookup_type type,
+								  pgpa_trove_entry **entries,
+								  int *nentries);
+extern char *pgpa_cstring_trove_entry(pgpa_trove_entry *entry);
+extern void pgpa_trove_set_flags(pgpa_trove_entry *entries,
+								 Bitmapset *indexes, int flags);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
new file mode 100644
index 00000000000..7e4e388603a
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -0,0 +1,862 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.c
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/plannodes.h"
+
+static void pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+								  bool within_join_problem,
+								  pgpa_join_unroller *join_unroller,
+								  List *active_query_features,
+								  bool beneath_any_gather);
+static Bitmapset *pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+											 pgpa_unrolled_join *ujoin);
+
+static pgpa_query_feature *pgpa_add_feature(pgpa_plan_walker_context *walker,
+											pgpa_qf_type type,
+											Plan *plan);
+
+static void pgpa_qf_add_rti(List *active_query_features, Index rti);
+static void pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids);
+static void pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan);
+
+static bool pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+										   Index rtable_length,
+										   pgpa_identifier *rt_identifiers,
+										   pgpa_advice_target *target,
+										   bool toplevel);
+static bool pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+												  Index rtable_length,
+												  pgpa_identifier *rt_identifiers,
+												  pgpa_advice_target *target);
+static bool pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+									  pgpa_scan_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+										 pgpa_qf_type type,
+										 Bitmapset *relids);
+static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+									  pgpa_join_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+										   Bitmapset *relids);
+static Index pgpa_walker_get_rti(Index rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid);
+
+/*
+ * Top-level entrypoint for the plan tree walk.
+ *
+ * Populates walker based on a traversal of the Plan trees in pstmt.
+ */
+void
+pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt)
+{
+	ListCell   *lc;
+
+	/* Initialization. */
+	memset(walker, 0, sizeof(pgpa_plan_walker_context));
+	walker->pstmt = pstmt;
+
+	/* Walk the main plan tree. */
+	pgpa_walk_recursively(walker, pstmt->planTree, 0, NULL, NIL, false);
+
+	/* Main plan tree walk won't reach subplans, so walk those. */
+	foreach(lc, pstmt->subplans)
+	{
+		Plan	   *plan = lfirst(lc);
+
+		if (plan != NULL)
+			pgpa_walk_recursively(walker, plan, 0, NULL, NIL, false);
+	}
+}
+
+/*
+ * Main workhorse for the plan tree walk.
+ *
+ * If within_join_problem is true, we encountered a join at some higher level
+ * of the tree walk and haven't yet descended out of the portion of the plan
+ * tree that is part of that same join problem. We're no longer in the same
+ * join problem if (1) we cross into a different subquery or (2) we descend
+ * through an Append or MergeAppend node, below which any further joins would
+ * be partitionwise joins planned separately from the outer join problem.
+ *
+ * If join_unroller != NULL, the join unroller code expects us to find a join
+ * that should be unrolled into that object. This implies that we're within a
+ * join problem, but the reverse is not true: when we've traversed all the
+ * joins but are still looking for the scan that is the leaf of the join tree,
+ * join_unroller will be NULL but within_join_problem will be true.
+ *
+ * Each element of active_query_features corresponds to some item of advice
+ * that needs to enumerate all the relations it affects. We add RTIs we find
+ * during tree traversal to each of these query features.
+ *
+ * If beneath_any_gather == true, some higher level of the tree traversal found
+ * a Gather or Gather Merge node.
+ */
+static void
+pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+					  bool within_join_problem,
+					  pgpa_join_unroller *join_unroller,
+					  List *active_query_features,
+					  bool beneath_any_gather)
+{
+	pgpa_join_unroller *outer_join_unroller = NULL;
+	pgpa_join_unroller *inner_join_unroller = NULL;
+	bool		join_unroller_toplevel = false;
+	List	   *pushdown_query_features = NIL;
+	ListCell   *lc;
+	List	   *extraplans = NIL;
+	List	   *elided_nodes = NIL;
+
+	Assert(within_join_problem || join_unroller == NULL);
+
+	/*
+	 * If this is a Gather or Gather Merge node, directly add it to the list
+	 * of currently-active query features.
+	 *
+	 * Otherwise, check the future_query_features list to see whether this was
+	 * previously identified as a plan node that needs to be treated as a
+	 * query feature.
+	 *
+	 * Note that the caller also has a copy to active_query_features, so we
+	 * can't destructively modify it without making a copy.
+	 */
+	if (IsA(plan, Gather))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER, plan));
+		beneath_any_gather = true;
+	}
+	else if (IsA(plan, GatherMerge))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER_MERGE, plan));
+		beneath_any_gather = true;
+	}
+	else
+	{
+		foreach_ptr(pgpa_query_feature, qf, walker->future_query_features)
+		{
+			if (qf->plan == plan)
+			{
+				active_query_features = list_copy(active_query_features);
+				active_query_features = lappend(active_query_features, qf);
+				walker->future_query_features =
+					list_delete_ptr(walker->future_query_features, plan);
+				break;
+			}
+		}
+	}
+
+	/*
+	 * Find all elided nodes for this Plan node.
+	 */
+	foreach_node(ElidedNode, n, walker->pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_nodes = lappend(elided_nodes, n);
+	}
+
+	/* If we found any elided_nodes, handle them. */
+	if (elided_nodes != NIL)
+	{
+		int			num_elided_nodes = list_length(elided_nodes);
+		ElidedNode *last_elided_node;
+
+		/*
+		 * RTIs for the final -- and thus logically uppermost -- elided node
+		 * should be collected for query features passed down by the caller.
+		 * However, elided nodes act as barriers to query features, which
+		 * means that (1) the remaining elided nodes, if any, should be
+		 * ignored for purposes of query features and (2) the list of active
+		 * query features should be reset to empty so that we do not add RTIs
+		 * from the plan node that is logically beneath the elided node to the
+		 * query features passed down from the caller.
+		 */
+		last_elided_node = list_nth(elided_nodes, num_elided_nodes - 1);
+		pgpa_qf_add_rtis(active_query_features, last_elided_node->relids);
+		active_query_features = NIL;
+
+		/*
+		 * If we're within a join problem, the join_unroller is responsible
+		 * for building the scan for the final elided node, so throw it out.
+		 */
+		if (within_join_problem)
+			elided_nodes = list_truncate(elided_nodes, num_elided_nodes - 1);
+
+		/* Build scans for all (or the remaining) elided nodes. */
+		foreach_node(ElidedNode, elided_node, elided_nodes)
+		{
+			(void) pgpa_build_scan(walker, plan, elided_node,
+								   beneath_any_gather, within_join_problem);
+		}
+
+		/*
+		 * If there were any elided nodes, then everything beneath those nodes
+		 * is not part of the same join problem.
+		 *
+		 * In more detail, if an Append or MergeAppend was elided, then a
+		 * partitionwise join was chosen and only a single child survived; if
+		 * a SubqueryScan was elided, the subquery was planned without
+		 * flattening it into the parent.
+		 */
+		within_join_problem = false;
+		join_unroller = NULL;
+	}
+
+	/*
+	 * If we're within a join problem, the join unroller is responsible for
+	 * building any required scan for this node. If not, we do it here.
+	 */
+	if (!within_join_problem)
+		(void) pgpa_build_scan(walker, plan, NULL, beneath_any_gather, false);
+
+	/*
+	 * If this join needs to unrolled but there's no join unroller already
+	 * available, create one.
+	 */
+	if (join_unroller == NULL && pgpa_is_join(plan))
+	{
+		join_unroller = pgpa_create_join_unroller();
+		join_unroller_toplevel = true;
+		within_join_problem = true;
+	}
+
+	/*
+	 * If this join is to be unrolled, pgpa_unroll_join() will return the join
+	 * unroller object that should be passed down when we recurse into the
+	 * outer and inner sides of the plan.
+	 */
+	if (join_unroller != NULL)
+		pgpa_unroll_join(walker, plan, beneath_any_gather, join_unroller,
+						 &outer_join_unroller, &inner_join_unroller);
+
+	/* Add RTIs from the plan node to all active query features. */
+	pgpa_qf_add_plan_rtis(active_query_features, plan);
+
+	/*
+	 * Recurse into the outer and inner subtrees.
+	 *
+	 * As an exception, if this is a ForeignScan, don't recurse. postgres_fdw
+	 * sometimes stores an EPQ recheck plan in plan->leftree, but that's going
+	 * to mention the same set of relations as the ForeignScan itself, and we
+	 * have no way to emit advice targeting the EPQ case vs. the non-EPQ case.
+	 * Moreover, it's not entirely clear what other FDWs might do with the
+	 * left and right subtrees. Maybe some better handling is needed here, but
+	 * for now, we just punt.
+	 */
+	if (!IsA(plan, ForeignScan))
+	{
+		if (plan->lefttree != NULL)
+			pgpa_walk_recursively(walker, plan->lefttree, within_join_problem,
+								  outer_join_unroller, active_query_features,
+								  beneath_any_gather);
+		if (plan->righttree != NULL)
+			pgpa_walk_recursively(walker, plan->righttree, within_join_problem,
+								  inner_join_unroller, active_query_features,
+								  beneath_any_gather);
+	}
+
+	/*
+	 * If we created a join unroller up above, then it's also our join to use
+	 * it to build the final pgpa_unrolled_join, and to destroy the object.
+	 */
+	if (join_unroller_toplevel)
+	{
+		pgpa_unrolled_join *ujoin;
+
+		ujoin = pgpa_build_unrolled_join(walker, join_unroller);
+		walker->toplevel_unrolled_joins =
+			lappend(walker->toplevel_unrolled_joins, ujoin);
+		pgpa_destroy_join_unroller(join_unroller);
+		(void) pgpa_process_unrolled_join(walker, ujoin);
+	}
+
+	/*
+	 * Some plan types can have additional children. Nodes like Append that
+	 * can have any number of children store them in a List; a SubqueryScan
+	 * just has a field for a single additional Plan.
+	 */
+	switch (nodeTag(plan))
+	{
+		case T_Append:
+			{
+				Append	   *aplan = (Append *) plan;
+
+				extraplans = aplan->appendplans;
+				if (bms_is_empty(aplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_MergeAppend:
+			{
+				MergeAppend *maplan = (MergeAppend *) plan;
+
+				extraplans = maplan->mergeplans;
+				if (bms_is_empty(maplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_BitmapAnd:
+			extraplans = ((BitmapAnd *) plan)->bitmapplans;
+			break;
+		case T_BitmapOr:
+			extraplans = ((BitmapOr *) plan)->bitmapplans;
+			break;
+		case T_SubqueryScan:
+
+			/*
+			 * We don't pass down active_query_features across here, because
+			 * those are specific to a subquery level.
+			 */
+			pgpa_walk_recursively(walker, ((SubqueryScan *) plan)->subplan,
+								  0, NULL, NIL, beneath_any_gather);
+			break;
+		case T_CustomScan:
+			extraplans = ((CustomScan *) plan)->custom_plans;
+			break;
+		default:
+			break;
+	}
+
+	/* If we found a list of extra children, iterate over it. */
+	foreach(lc, extraplans)
+	{
+		Plan	   *subplan = lfirst(lc);
+
+		pgpa_walk_recursively(walker, subplan, 0, NULL, pushdown_query_features,
+							  beneath_any_gather);
+	}
+}
+
+/*
+ * Perform final processing of a newly-constructed pgpa_unrolled_join. This
+ * only needs to be called for toplevel pgpa_unrolled_join objects, since it
+ * recurses to sub-joins as needed.
+ *
+ * Our goal is to add the set of inner relids to the relevant join_strategies
+ * list, and to do the same for any sub-joins. To that end, the return value
+ * is the set of relids found beneath the inner side of the join, but it is
+ * expected that the toplevel caller will ignore this.
+ */
+static Bitmapset *
+pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+						   pgpa_unrolled_join *ujoin)
+{
+	Bitmapset  *all_relids = NULL;
+
+	for (int k = 0; k < ujoin->ninner; ++k)
+	{
+		pgpa_join_member *member = &ujoin->inner[k];
+		Bitmapset  *relids;
+
+		if (member->unrolled_join != NULL)
+			relids = pgpa_process_unrolled_join(walker,
+												member->unrolled_join);
+		else
+		{
+			Assert(member->scan != NULL);
+			relids = member->scan->relids;
+		}
+		walker->join_strategies[ujoin->strategy[k]] =
+			lappend(walker->join_strategies[ujoin->strategy[k]], relids);
+		all_relids = bms_add_members(all_relids, relids);
+	}
+
+	return all_relids;
+}
+
+/*
+ * Arrange for the given plan node to be treated as a query feature when the
+ * tree walk reaches it.
+ *
+ * Make sure to only use this for nodes that the tree walk can't have reached
+ * yet!
+ */
+void
+pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+						pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = pgpa_add_feature(walker, type, plan);
+
+	walker->future_query_features =
+		lappend(walker->future_query_features, qf);
+}
+
+/*
+ * Return the last of any elided nodes associated with this plan node ID.
+ *
+ * The last elided node is the one that would have been uppermost in the plan
+ * tree had it not been removed during setrefs processig.
+ */
+ElidedNode *
+pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan)
+{
+	ElidedNode *elided_node = NULL;
+
+	foreach_node(ElidedNode, n, pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_node = n;
+	}
+
+	return elided_node;
+}
+
+/*
+ * Certain plan nodes can refer to a set of RTIs. Extract and return the set.
+ */
+Bitmapset *
+pgpa_relids(Plan *plan)
+{
+	if (IsA(plan, Result))
+		return ((Result *) plan)->relids;
+	else if (IsA(plan, ForeignScan))
+		return ((ForeignScan *) plan)->fs_relids;
+	else if (IsA(plan, Append))
+		return ((Append *) plan)->apprelids;
+	else if (IsA(plan, MergeAppend))
+		return ((MergeAppend *) plan)->apprelids;
+
+	return NULL;
+}
+
+/*
+ * Extract the scanned RTI from a plan node.
+ *
+ * Returns 0 if there isn't one.
+ */
+Index
+pgpa_scanrelid(Plan *plan)
+{
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+		case T_SubqueryScan:
+		case T_FunctionScan:
+		case T_TableFuncScan:
+		case T_ValuesScan:
+		case T_CteScan:
+		case T_NamedTuplestoreScan:
+		case T_WorkTableScan:
+		case T_ForeignScan:
+		case T_CustomScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+			return ((Scan *) plan)->scanrelid;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Create a pgpa_query_feature and add it to the list of all query features
+ * for this plan.
+ */
+static pgpa_query_feature *
+pgpa_add_feature(pgpa_plan_walker_context *walker,
+				 pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = palloc0_object(pgpa_query_feature);
+
+	qf->type = type;
+	qf->plan = plan;
+
+	walker->query_features[qf->type] =
+		lappend(walker->query_features[qf->type], qf);
+
+	return qf;
+}
+
+/*
+ * Add a single RTI to each active query feature.
+ */
+static void
+pgpa_qf_add_rti(List *active_query_features, Index rti)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_member(qf->relids, rti);
+	}
+}
+
+/*
+ * Add a set of RTIs to each active query feature.
+ */
+static void
+pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_members(qf->relids, relids);
+	}
+}
+
+/*
+ * Add RTIs directly contained in a plan node to each active query feature.
+ */
+static void
+pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan)
+{
+	Bitmapset  *relids;
+	Index		rti;
+
+	if ((relids = pgpa_relids(plan)) != NULL)
+		pgpa_qf_add_rtis(active_query_features, relids);
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+		pgpa_qf_add_rti(active_query_features, rti);
+}
+
+/*
+ * If we generated plan advice using the provided walker object and array
+ * of identifiers, would we generate the specified tag/target combination?
+ *
+ * If yes, the plan conforms to the advice; if no, it does not. Note that
+ * we have know way of knowing whether the planner was forced to emit a plan
+ * that conformed to the advice or just happened to do so.
+ */
+bool
+pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+						 pgpa_identifier *rt_identifiers,
+						 pgpa_advice_tag_type tag,
+						 pgpa_advice_target *target)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	Bitmapset  *relids = NULL;
+
+	if (tag == PGPA_TAG_JOIN_ORDER)
+	{
+		foreach_ptr(pgpa_unrolled_join, ujoin, walker->toplevel_unrolled_joins)
+		{
+			if (pgpa_walker_join_order_matches(ujoin, rtable_length,
+											   rt_identifiers, target, true))
+				return true;
+		}
+
+		return false;
+	}
+
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		Index		rti;
+
+		rti = pgpa_walker_get_rti(rtable_length, rt_identifiers, &target->rid);
+		relids = bms_make_singleton(rti);
+	}
+	else
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			Index		rti;
+
+			Assert(child_target->ttype == PGPA_TARGET_IDENTIFIER);
+			rti = pgpa_compute_rti_from_identifier(rtable_length,
+												   rt_identifiers,
+												   &child_target->rid);
+			if (rti == 0)
+				elog(ERROR, "cannot determine RTI for advice target");
+			relids = bms_add_member(relids, rti);
+		}
+	}
+
+	switch (tag)
+	{
+		case PGPA_TAG_JOIN_ORDER:
+			/* should have been handled above */
+			pg_unreachable();
+			break;
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_BITMAP_HEAP,
+											 relids);
+		case PGPA_TAG_FOREIGN_JOIN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_FOREIGN,
+											 relids);
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX_ONLY,
+											 relids);
+		case PGPA_TAG_INDEX_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX,
+											 relids);
+		case PGPA_TAG_PARTITIONWISE:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_PARTITIONWISE,
+											 relids);
+		case PGPA_TAG_SEQ_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_SEQ,
+											 relids);
+		case PGPA_TAG_TID_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_TID,
+											 relids);
+		case PGPA_TAG_GATHER:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER,
+												relids);
+		case PGPA_TAG_GATHER_MERGE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER_MERGE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_NON_UNIQUE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_UNIQUE,
+												relids);
+		case PGPA_TAG_HASH_JOIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_HASH_JOIN,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_PLAIN,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MEMOIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_PLAIN,
+											 relids);
+		case PGPA_TAG_NO_GATHER:
+			return pgpa_walker_contains_no_gather(walker, relids);
+	}
+
+	/* should not get here */
+	return false;
+}
+
+/*
+ * Does an unrolled join match the join order specified by an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+							   Index rtable_length,
+							   pgpa_identifier *rt_identifiers,
+							   pgpa_advice_target *target,
+							   bool toplevel)
+{
+	int		nchildren = list_length(target->children);
+
+	Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	/* At toplevel, we allow a prefix match. */
+	if (toplevel)
+	{
+		if (nchildren > ujoin->ninner + 1)
+			return false;
+	}
+	else
+	{
+		if (nchildren != ujoin->ninner + 1)
+			return false;
+	}
+
+	/* Outermost rel must match. */
+	if (!pgpa_walker_join_order_matches_member(&ujoin->outer,
+											   rtable_length,
+											   rt_identifiers,
+											   linitial(target->children)))
+		return false;
+
+	/* Each inner rel must match. */
+	for (int n = 0; n < nchildren - 1; ++n)
+	{
+		pgpa_advice_target *child_target = list_nth(target->children, n + 1);
+
+		if (!pgpa_walker_join_order_matches_member(&ujoin->inner[n],
+												   rtable_length,
+												   rt_identifiers,
+												   child_target))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Does one member of an unrolled join match an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+									  Index rtable_length,
+									  pgpa_identifier *rt_identifiers,
+									  pgpa_advice_target *target)
+{
+	Bitmapset  *relids = NULL;
+
+	if (member->unrolled_join != NULL)
+	{
+		if (target->ttype != PGPA_TARGET_ORDERED_LIST)
+			return false;
+		return pgpa_walker_join_order_matches(member->unrolled_join,
+											  rtable_length,
+											  rt_identifiers,
+											  target,
+											  false);
+	}
+
+	Assert(member->scan != NULL);
+	switch (target->ttype)
+	{
+		case PGPA_TARGET_ORDERED_LIST:
+			/* Could only match an unrolled join */
+			return false;
+
+		case PGPA_TARGET_UNORDERED_LIST:
+			{
+				foreach_ptr(pgpa_advice_target, child_target, target->children)
+				{
+					Index		rti;
+
+					rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+											  &child_target->rid);
+					relids = bms_add_member(relids, rti);
+				}
+				break;
+			}
+
+		case PGPA_TARGET_IDENTIFIER:
+			{
+				Index		rti;
+
+				rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+										  &target->rid);
+				relids = bms_make_singleton(rti);
+				break;
+			}
+	}
+
+	return bms_equal(member->scan->relids, relids);
+}
+
+/*
+ * Does this walker say that the given scan strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+						  pgpa_scan_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *scans = walker->scans[strategy];
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		/*
+		 * XXX. If this is index-related advice, we should also validate that
+		 * the advice target's index target matches the Plan tree.
+		 */
+		if (bms_equal(scan->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does this walker say that the given query feature applies to the given
+ * relid set?
+ */
+static bool
+pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+							 pgpa_qf_type type,
+							 Bitmapset *relids)
+{
+	List	   *query_features = walker->query_features[type];
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (bms_equal(qf->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given join strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+						  pgpa_join_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *join_strategies = walker->join_strategies[strategy];
+
+	foreach_ptr(Bitmapset, jsrelids, join_strategies)
+	{
+		if (bms_equal(jsrelids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given relids should be marked as NO_GATHER?
+ */
+static bool
+pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+							   Bitmapset *relids)
+{
+	return bms_is_subset(relids, walker->no_gather_scans);
+}
+
+/*
+ * Convenience function to convert a relation identifier to an RTI.
+ *
+ * We throw an error here because we expect this to be used on system-generated
+ * advice. Hence, failure here indicates an advice generation bug.
+ */
+static Index
+pgpa_walker_get_rti(Index rtable_length,
+					pgpa_identifier *rt_identifiers,
+					pgpa_identifier *rid)
+{
+	Index		rti;
+
+	rti = pgpa_compute_rti_from_identifier(rtable_length,
+										   rt_identifiers,
+										   rid);
+	if (rti == 0)
+		elog(ERROR, "cannot determine RTI for advice target");
+	return rti;
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
new file mode 100644
index 00000000000..d6584c014b9
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -0,0 +1,121 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.h
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_WALKER_H
+#define PGPA_WALKER_H
+
+#include "pgpa_ast.h"
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+
+/*
+ * We use the term "query feature" to refer to plan nodes that are interesting
+ * in the following way: to generate advice, we'll need to know the set of
+ * same-subquery, non-join RTIs occuring at or below that plan node, without
+ * admixture of parent and child RTIs.
+ *
+ * For example, Gather nodes, desiginated by PGPAQF_GATHER, and Gather Merge
+ * nodes, designated by PGPAQF_GATHER_MERGE, are query features, because we'll
+ * want to admit some kind of advice that describes the portion of the plan
+ * tree that appears beneath those nodes.
+ *
+ * Each semijoin can be implemented either by directly performing a semijoin,
+ * or by making one side unique and then performing a normal join. Either way,
+ * we use a query feature to notice what decision was made, so that we can
+ * describe it by enumerating the RTIs on that side of the join.
+ *
+ * To elaborate on the "no admixture of parent and child RTIs" rule, in all of
+ * these cases, if the entirety of an inheritance hierarchy appears beneath
+ * the query feature, we only want to name the parent table. But it's also
+ * possible to have cases where we must name child tables. This is particularly
+ * likely to happen when partitionwise join is in use, but could happen for
+ * Gather or Gather Merge even without that, if one of those appears below
+ * an Append or MergeAppend node for a single table.
+ */
+typedef enum pgpa_qf_type
+{
+	PGPAQF_GATHER,
+	PGPAQF_GATHER_MERGE,
+	PGPAQF_SEMIJOIN_NON_UNIQUE,
+	PGPAQF_SEMIJOIN_UNIQUE
+	/* update NUM_PGPA_QF_TYPES if you add anything here */
+} pgpa_qf_type;
+
+#define NUM_PGPA_QF_TYPES ((int) PGPAQF_SEMIJOIN_UNIQUE + 1)
+
+/*
+ * For each query feature, we keep track of the feature type and the set of
+ * relids that we found underneath the relevant plan node. See the comments
+ * on pgpa_qf_type, above, for additional details.
+ */
+typedef struct pgpa_query_feature
+{
+	pgpa_qf_type type;
+	Plan	   *plan;
+	Bitmapset  *relids;
+} pgpa_query_feature;
+
+/*
+ * Context object for plan tree walk.
+ *
+ * pstmt is the PlannedStmt we're studying.
+ *
+ * scans is an array of lists of pgpa_scan objects. The array is indexed by
+ * the scan's pgpa_scan_strategy.
+ *
+ * no_gather_scans is the set of scan RTIs that do not appear beneath any
+ * Gather or Gather Merge node.
+ *
+ * toplevel_unrolled_joins is a list of all pgpa_unrolled_join objects that
+ * are not a child of some other pgpa_unrolled_join.
+ *
+ * join_strategy is an array of lists of Bitmapset objects. Each Bitmapset
+ * is the set of relids that appears on the inner side of some join (excluding
+ * RTIs from partition children and subqueries). The array is indexed by
+ * pgpa_join_strategy.
+ *
+ * query_features is an array lists of pgpa_query_feature objects, indexed
+ * by pgpa_qf_type.
+ *
+ * future_query_features is only used during the plan tree walk and should
+ * be empty when the tree walk concludes. It is a list of pgpa_query_feature
+ * objects for Plan nodes that the plan tree walk has not yet encountered;
+ * when encountered, they will be moved to the list of active query features
+ * that is propagated via the call stack.
+ */
+typedef struct pgpa_plan_walker_context
+{
+	PlannedStmt *pstmt;
+	List	   *scans[NUM_PGPA_SCAN_STRATEGY];
+	Bitmapset  *no_gather_scans;
+	List	   *toplevel_unrolled_joins;
+	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
+	List	   *query_features[NUM_PGPA_QF_TYPES];
+	List	   *future_query_features;
+} pgpa_plan_walker_context;
+
+extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
+							 PlannedStmt *pstmt);
+
+extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+									pgpa_qf_type type,
+									Plan *plan);
+
+extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
+extern Bitmapset *pgpa_relids(Plan *plan);
+extern Index pgpa_scanrelid(Plan *plan);
+
+extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+									 pgpa_identifier *rt_identifiers,
+									 pgpa_advice_tag_type tag,
+									 pgpa_advice_target *target);
+
+#endif
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
new file mode 100644
index 00000000000..58280043913
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_order.sql b/contrib/pg_plan_advice/sql/join_order.sql
new file mode 100644
index 00000000000..5aa2fc62d34
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_order.sql
@@ -0,0 +1,96 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
+
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/sql/join_strategy.sql b/contrib/pg_plan_advice/sql/join_strategy.sql
new file mode 100644
index 00000000000..8eb823f1c0e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_strategy.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/local_collector.sql b/contrib/pg_plan_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..be14539280e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/local_collector.sql
@@ -0,0 +1,40 @@
+CREATE EXTENSION pg_plan_advice;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_plan_advice/sql/partitionwise.sql b/contrib/pg_plan_advice/sql/partitionwise.sql
new file mode 100644
index 00000000000..e42c0611760
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/partitionwise.sql
@@ -0,0 +1,78 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
new file mode 100644
index 00000000000..25416a75f46
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -0,0 +1,195 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+COMMIT;
+
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+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 > 0;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+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;
+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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+COMMIT;
+
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/syntax.sql b/contrib/pg_plan_advice/sql/syntax.sql
new file mode 100644
index 00000000000..8bc1b71bebe
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/syntax.sql
@@ -0,0 +1,42 @@
+LOAD 'pg_plan_advice';
+
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+SET pg_plan_advice.advice = '()';
+SET pg_plan_advice.advice = '123';
+
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
diff --git a/contrib/pg_plan_advice/t/001_regress.pl b/contrib/pg_plan_advice/t/001_regress.pl
new file mode 100644
index 00000000000..dffafcad6dc
--- /dev/null
+++ b/contrib/pg_plan_advice/t/001_regress.pl
@@ -0,0 +1,139 @@
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_plan_advice to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+#
+# We run with pg_plan_advice.shared_collection_limit set to ensure that the
+# plan tree walker code runs against every query in the regression tests. If
+# we're unable to properly analyze any of those plan trees, this test should fail.
+#
+# We set pg_plan_advice.advice to an advice string that will cause the advice
+# trove to be populated with a few entries of various sorts, but which we do
+# not expect to match anything in the regression test queries. This way, the
+# planner hooks will be called, improving code coverage, but no plans should
+# actually change.
+#
+# pg_plan_advice.always_explain_supplied_advice=false is needed to avoid breaking
+# regression test queries that use EXPLAIN. In the real world, it seems like
+# users will want EXPLAIN output to show supplied advice so that it's clear
+# whether normal planner behavior has been altered, but here that's undesirable.
+$node->append_conf('postgresql.conf', <<EOM);
+pg_plan_advice.shared_collection_limit=1000000
+shared_preload_libraries=pg_plan_advice
+pg_plan_advice.advice='SEQ_SCAN(entirely_fictitious) HASH_JOIN(total_fabrication) GATHER(completely_imaginary)'
+pg_plan_advice.always_explain_supplied_advice=false
+EOM
+$node->start;
+
+my $srcdir = abs_path("../..");
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+# Create the extension so we can access the collector
+$node->safe_psql('postgres', 'CREATE EXTENSION pg_plan_advice');
+
+# Verify that a large amount of advice was collected
+my $all_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice();
+EOM
+cmp_ok($all_query_count, '>', 40000, "copious advice collected");
+
+# Verify that lots of different advice strings were collected
+my $distinct_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM
+	(SELECT DISTINCT advice FROM pg_get_collected_shared_advice());
+EOM
+cmp_ok($distinct_query_count, '>', 3000, "diverse advice collected");
+
+# We want to test for the presence of our known tags in the collected advice.
+# Put all tags into the hash that follows; map any tags that aren't tested
+# by the core regression tests to 0, and others to 1.
+my %tag_map = (
+	BITMAP_HEAP_SCAN => 1,
+	FOREIGN_JOIN => 0,
+	GATHER => 1,
+	GATHER_MERGE => 1,
+	HASH_JOIN => 1,
+	INDEX_ONLY_SCAN => 1,
+	INDEX_SCAN => 1,
+	JOIN_ORDER => 1,
+	MERGE_JOIN_MATERIALIZE => 1,
+	MERGE_JOIN_PLAIN => 1,
+	NESTED_LOOP_MATERIALIZE => 1,
+	NESTED_LOOP_MEMOIZE => 1,
+	NESTED_LOOP_PLAIN => 1,
+	NO_GATHER => 1,
+	PARTITIONWISE => 1,
+	SEMIJOIN_NON_UNIQUE => 1,
+	SEMIJOIN_UNIQUE => 1,
+	SEQ_SCAN => 1,
+	TID_SCAN => 1,
+);
+while (my ($tag, $checkit) = each %tag_map)
+{
+	# Search for the given tag. This is not entirely robust: it could get thrown
+	# off by a table alias such as "FOREIGN_JOIN(", but that probably won't
+	# happen in the core regression tests.
+	my $tag_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice()
+	WHERE advice LIKE '%$tag(%'
+EOM
+
+	# Check that the tag got a non-trivial amount of use, unless told otherwise.
+	cmp_ok($tag_count, '>', 10, "multiple uses of $tag") if $checkit;
+
+	# Regardless, note the exact count in the log, for human consumption.
+	note("found $tag_count advice strings containing $tag");
+}
+
+# Trigger a partial cleanup of the shared advice collector, and then a full
+# cleanup.
+$node->safe_psql('postgres', <<EOM);
+SET pg_plan_advice.shared_collection_limit=500;
+SELECT * FROM pg_clear_collected_shared_advice();
+EOM
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 4ff47115ca8..d1a7e5f8c46 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3928,6 +3928,43 @@ pg_wc_probefunc
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgpa_collected_advice
+pgpa_advice_item
+pgpa_advice_tag_type
+pgpa_advice_target
+pgpa_identifier
+pgpa_index_target
+pgpa_index_type
+pgpa_itm_type
+pgpa_join_class
+pgpa_join_member
+pgpa_join_state
+pgpa_join_strategy
+pgpa_join_unroller
+pgpa_local_advice
+pgpa_local_advice_chunk
+pgpa_output_context
+pgpa_plan_walker_context
+pgpa_planner_state
+pgpa_qf_type
+pgpa_query_feature
+pgpa_ri_checker
+pgpa_ri_checker_key
+pgpa_scan
+pgpa_scan_strategy
+pgpa_shared_advice
+pgpa_shared_advice_chunk
+pgpa_shared_state
+pgpa_target_type
+pgpa_trove
+pgpa_trove_entry
+pgpa_trove_entry_element
+pgpa_trove_entry_hash
+pgpa_trove_entry_key
+pgpa_trove_lookup_type
+pgpa_trove_result
+pgpa_trove_slice
+pgpa_unrolled_join
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-17 14:42  Matheus Alcantara <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Matheus Alcantara @ 2025-11-17 14:42 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; Jakub Wartak <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

Hi

On Thu Nov 6, 2025 at 1:45 PM -03, Robert Haas wrote:
> Here's v3. I've attempted to fix some more things that cfbot didn't
> like, one of which was an actual bug in 0005, and I also fixed a
> stupid few bugs in pgpa_collector.c and added a few more tests.
>
I've spent some time playing with these patches. I still don't have to
much comments on the syntax yet but I've noticed a small bug or perhaps
I'm missing something?

When I run CREATE EXTENSION pg_plan_advice I'm able to use the
EXPLAIN(plan_advice) but if try to open another connection, with the
extension already previously created, I'm unable to use once I drop and
re-create the extension.

tpch=# create extension pg_plan_advice;
ERROR:  extension "pg_plan_advice" already exists
tpch=# explain(plan_advice) select 1;
ERROR:  unrecognized EXPLAIN option "plan_advice"
LINE 1: explain(plan_advice) select 1;
                ^
tpch=# drop extension pg_plan_advice ;
DROP EXTENSION
tpch=# create extension pg_plan_advice;
CREATE EXTENSION
tpch=# explain(plan_advice) select 1;
                QUERY PLAN
------------------------------------------
 Result  (cost=0.00..0.01 rows=1 width=4)
 Generated Plan Advice:
   NO_GATHER("*RESULT*")


And thanks for working on this. I think that this can be a very useful
feature for both users and for postgres hackers, +1 for the idea.

-- 
Matheus Alcantara
EDB: http://www.enterprisedb.com






^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-17 15:09  Robert Haas <[email protected]>
  parent: Matheus Alcantara <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-11-17 15:09 UTC (permalink / raw)
  To: Matheus Alcantara <[email protected]>; +Cc: Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Nov 17, 2025 at 9:42 AM Matheus Alcantara
<[email protected]> wrote:
> I've spent some time playing with these patches. I still don't have to
> much comments on the syntax yet but I've noticed a small bug or perhaps
> I'm missing something?

Cool, thanks for looking. I am guessing that the paucity of feedback
thus far is partly because there's a lot of stuff to absorb -- though
the main point at this stage is really to get some opinions on the
planner infrastructure/hooks, which don't necessarily require full
understanding of (never mind agreement with) the design of
pg_plan_advice itself.

> When I run CREATE EXTENSION pg_plan_advice I'm able to use the
> EXPLAIN(plan_advice) but if try to open another connection, with the
> extension already previously created, I'm unable to use once I drop and
> re-create the extension.

This is just an idiosyncrasy of PostgreSQL's extension framework.
Whether or not EXPLAIN (PLAN_ADVICE) works depends on whether the
shared module has been loaded, not whether the extension has been
created. The purpose of CREATE EXTENSION is to put SQL objects, such
as function definitions, into the database, but there's no SQL
required to enable EXPLAIN (PLAN_ADVICE) -- or for setting the
pg_plan_advice.advice GUC. However, running CREATE EXTENSION to
establish the function definitions will incidentally load the shared
module into that particular session.

Therefore, the best way to use this module is to add pg_plan_advice to
shared_preload_libraries. Alternatively, you can use
session_preload_libraries or run LOAD in an individual session. If you
don't care about the collector interface, that's really all you need.
If you do care about the collector interface, then in addition you
will need to run CREATE EXTENSION, so that the SQL functions needed to
access it are available.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-18 16:19  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-11-18 16:19 UTC (permalink / raw)
  To: Matheus Alcantara <[email protected]>; +Cc: Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

Here's v4. This version has some bug fixes and test case changes to
0005 and 0006, with the goal of getting CI to pass cleanly (which it
now does for me, but let's see if cfbot agrees).

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v4-0001-Store-information-about-range-table-flattening-in.patch (7.9K, 2-v4-0001-Store-information-about-range-table-flattening-in.patch)
  download | inline diff:
From 879a0f720c046a640c44754c5264c51bbf0214df Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 12:00:18 -0400
Subject: [PATCH v4 1/6] Store information about range-table flattening in the
 final plan.

Suppose that we're currently planning a query and, when that same
query was previously planned and executed, we learned something about
how a certain table within that query should be planned. We want to
take note when that same table is being planned during the current
planning cycle, but this is difficult to do, because the RTI of the
table from the previous plan won't necessarily be equal to the RTI
that we see during the current planning cycle. This is because each
subquery has a separate range table during planning, but these are
flattened into one range table when constructing the final plan,
changing RTIs.

Commit 8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0 allows us to match up
subqueries seen in the previous planning cycles with the subqueries
currently being planned just by comparing textual names, but that's
not quite enough to let us deduce anything about individual tables,
because we don't know where each subquery's range table appears in
the final, flattened range table.

To fix that, store a list of SubPlanRTInfo objects in the final
planned statement, each including the name of the subplan, the offset
at which it begins in the flattened range table, and whether or not
it was a dummy subplan -- if it was, some RTIs may have been dropped
from the final range table, but also there's no need to control how
a dummy subquery gets planned. The toplevel subquery has no name and
always begins at rtoffset 0, so we make no entry for it.

This commit teaches pg_overexplain'e RANGE_TABLE option to make use
of this new data to display the subquery name for each range table
entry.

NOTE TO REVIEWERS: If there's a clean way to make pg_overexplain display
this information without the new infrastructure provided by this patch,
then this patch is unnecessary. I thought there would be a way to do
that, but I couldn't figure anything out: there seems to be nothing that
records in the final PlannedStmt where subquery's range table ends and
the next one begins. In practice, one could usually figure it out by
matching up tables by relation OID, but that's neither clean nor
theoretically sound.
---
 contrib/pg_overexplain/pg_overexplain.c | 36 +++++++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    |  1 +
 src/backend/optimizer/plan/setrefs.c    | 20 ++++++++++++++
 src/include/nodes/pathnodes.h           |  3 +++
 src/include/nodes/plannodes.h           | 17 ++++++++++++
 src/tools/pgindent/typedefs.list        |  1 +
 6 files changed, 78 insertions(+)

diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index bd70b6d9d5e..5dc707d69e3 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -395,6 +395,8 @@ static void
 overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 {
 	Index		rti;
+	ListCell   *lc_subrtinfo = list_head(plannedstmt->subrtinfos);
+	SubPlanRTInfo *rtinfo = NULL;
 
 	/* Open group, one entry per RangeTblEntry */
 	ExplainOpenGroup("Range Table", "Range Table", false, es);
@@ -405,6 +407,18 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 		RangeTblEntry *rte = rt_fetch(rti, plannedstmt->rtable);
 		char	   *kind = NULL;
 		char	   *relkind;
+		SubPlanRTInfo *next_rtinfo;
+
+		/* Advance to next SubRTInfo, if it's time. */
+		if (lc_subrtinfo != NULL)
+		{
+			next_rtinfo = lfirst(lc_subrtinfo);
+			if (rti > next_rtinfo->rtoffset)
+			{
+				rtinfo = next_rtinfo;
+				lc_subrtinfo = lnext(plannedstmt->subrtinfos, lc_subrtinfo);
+			}
+		}
 
 		/* NULL entries are possible; skip them */
 		if (rte == NULL)
@@ -469,6 +483,28 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 			ExplainPropertyBool("In From Clause", rte->inFromCl, es);
 		}
 
+		/*
+		 * Indicate which subplan is the origin of which RTE. Note dummy
+		 * subplans. Here again, we crunch more onto one line in text format.
+		 */
+		if (rtinfo != NULL)
+		{
+			if (es->format == EXPLAIN_FORMAT_TEXT)
+			{
+				if (!rtinfo->dummy)
+					ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				else
+					ExplainPropertyText("Subplan",
+										psprintf("%s (dummy)",
+												 rtinfo->plan_name), es);
+			}
+			else
+			{
+				ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				ExplainPropertyBool("Subplan Is Dummy", rtinfo->dummy, es);
+			}
+		}
+
 		/* rte->alias is optional; rte->eref is requested */
 		if (rte->alias != NULL)
 			overexplain_alias("Alias", rte->alias, es);
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index c4fd646b999..0e6b3f60f31 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -607,6 +607,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->unprunableRelids = bms_difference(glob->allRelids,
 											  glob->prunableRelids);
 	result->permInfos = glob->finalrteperminfos;
+	result->subrtinfos = glob->subrtinfos;
 	result->resultRelations = glob->resultRelations;
 	result->appendRelations = glob->appendRelations;
 	result->subplans = glob->subplans;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ccdc9bc264a..adabae09a23 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -399,6 +399,26 @@ add_rtes_to_flat_rtable(PlannerInfo *root, bool recursing)
 	Index		rti;
 	ListCell   *lc;
 
+	/*
+	 * Record enough information to make it possible for code that looks at
+	 * the final range table to understand how it was constructed. (If
+	 * finalrtable is still NIL, then this is the very topmost PlannerInfo,
+	 * which will always have plan_name == NULL and rtoffset == 0; we omit the
+	 * degenerate list entry.)
+	 */
+	if (root->glob->finalrtable != NIL)
+	{
+		SubPlanRTInfo *rtinfo = makeNode(SubPlanRTInfo);
+
+		rtinfo->plan_name = root->plan_name;
+		rtinfo->rtoffset = list_length(root->glob->finalrtable);
+
+		/* When recursing = true, it's an unplanned or dummy subquery. */
+		rtinfo->dummy = recursing;
+
+		root->glob->subrtinfos = lappend(root->glob->subrtinfos, rtinfo);
+	}
+
 	/*
 	 * Add the query's own RTEs to the flattened rangetable.
 	 *
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 30d889b54c5..a3a800869df 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -135,6 +135,9 @@ typedef struct PlannerGlobal
 	/* "flat" list of RTEPermissionInfos */
 	List	   *finalrteperminfos;
 
+	/* list of SubPlanRTInfo nodes */
+	List	   *subrtinfos;
+
 	/* "flat" list of PlanRowMarks */
 	List	   *finalrowmarks;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index c4393a94321..1526dd2ec6b 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -131,6 +131,9 @@ typedef struct PlannedStmt
 	 */
 	List	   *subplans;
 
+	/* a list of SubPlanRTInfo objects */
+	List	   *subrtinfos;
+
 	/* indices of subplans that require REWIND */
 	Bitmapset  *rewindPlanIDs;
 
@@ -1821,4 +1824,18 @@ typedef enum MonotonicFunction
 	MONOTONICFUNC_BOTH = MONOTONICFUNC_INCREASING | MONOTONICFUNC_DECREASING,
 } MonotonicFunction;
 
+/*
+ * SubPlanRTInfo
+ *
+ * Information about which range table entries came from which subquery
+ * planning cycles.
+ */
+typedef struct SubPlanRTInfo
+{
+	NodeTag		type;
+	const char *plan_name;
+	Index		rtoffset;
+	bool		dummy;
+} SubPlanRTInfo;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 23bce72ae64..9da8ad99a20 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2889,6 +2889,7 @@ SubLink
 SubLinkType
 SubOpts
 SubPlan
+SubPlanRTInfo
 SubPlanState
 SubRelInfo
 SubRemoveRels
-- 
2.51.0



  [application/octet-stream] v4-0003-Store-information-about-Append-node-consolidation.patch (27.0K, 3-v4-0003-Store-information-about-Append-node-consolidation.patch)
  download | inline diff:
From eb93fdb51c45a72f4500ff4149ec1cc43ee20f42 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:07 -0400
Subject: [PATCH v4 3/6] Store information about Append node consolidation in
 the final plan.

An extension (or core code) might want to reconstruct the planner's
decisions about whether and where to perform partitionwise joins from
the final plan. To do so, it must be possible to find all of the RTIs
of partitioned tables appearing in the plan. But when an AppendPath
or MergeAppendPath pulls up child paths from a subordinate AppendPath
or MergeAppendPath, the RTIs of the subordinate path do not appear
in the final plan, making this kind of reconstruction impossible.

To avoid this, propagate the RTI sets that would have been present
in the 'apprelids' field of the subordinate Append or MergeAppend
nodes that would have been created into the surviving Append or
MergeAppend node, using a new 'child_append_relid_sets' field for
that purpose. The value of this field is a list of Bitmapsets,
because each relation whose append-list was pulled up had its own
set of RTIs: just one, if it was a partitionwise scan, or more than
one, if it was a partitionwise join. Since our goal is to see where
partitionwise joins were done, it is essential to avoid losing the
information about how the RTIs were grouped in the pulled-up
relations.

This commit also updates pg_overexplain so that EXPLAIN (RANGE_TABLE)
will display the saved RTI sets.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 56 +++++++++++
 src/backend/optimizer/path/allpaths.c         | 98 +++++++++++++++----
 src/backend/optimizer/path/joinrels.c         |  2 +-
 src/backend/optimizer/plan/createplan.c       |  2 +
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/prep/prepunion.c        | 11 ++-
 src/backend/optimizer/util/pathnode.c         |  5 +
 src/include/nodes/pathnodes.h                 | 10 ++
 src/include/nodes/plannodes.h                 | 11 +++
 src/include/optimizer/pathnode.h              |  2 +
 11 files changed, 175 insertions(+), 27 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index ca9a23ea61f..a377fb2571d 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -104,6 +104,7 @@ $$);
                Parallel Safe: true
                Plan Node ID: 2
                Append RTIs: 1
+               Child Append RTIs: none
                ->  Seq Scan on brassica vegetables_1
                      Disabled Nodes: 0
                      Parallel Safe: true
@@ -142,7 +143,7 @@ $$);
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 3 4
-(53 rows)
+(54 rows)
 
 -- Test a different output format.
 SELECT explain_filter($$
@@ -197,6 +198,7 @@ $$);
                <extParam>none</extParam>                            +
                <allParam>none</allParam>                            +
                <Append-RTIs>1</Append-RTIs>                         +
+               <Child-Append-RTIs>none</Child-Append-RTIs>          +
                <Subplans-Removed>0</Subplans-Removed>               +
                <Plans>                                              +
                  <Plan>                                             +
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index fa907fa472e..6538ffcafb0 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -54,6 +54,8 @@ static void overexplain_alias(const char *qlabel, Alias *alias,
 							  ExplainState *es);
 static void overexplain_bitmapset(const char *qlabel, Bitmapset *bms,
 								  ExplainState *es);
+static void overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+									   ExplainState *es);
 static void overexplain_intlist(const char *qlabel, List *list,
 								ExplainState *es);
 
@@ -232,11 +234,17 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 				overexplain_bitmapset("Append RTIs",
 									  ((Append *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((Append *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_MergeAppend:
 				overexplain_bitmapset("Append RTIs",
 									  ((MergeAppend *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((MergeAppend *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_Result:
 
@@ -815,6 +823,54 @@ overexplain_bitmapset(const char *qlabel, Bitmapset *bms, ExplainState *es)
 	pfree(buf.data);
 }
 
+/*
+ * Emit a text property describing the contents of a list of bitmapsets.
+ * If a bitmapset contains exactly 1 member, we just print an integer;
+ * otherwise, we surround the list of members by parentheses.
+ *
+ * If there are no bitmapsets in the list, we print the word "none".
+ */
+static void
+overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+						   ExplainState *es)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+
+	foreach_node(Bitmapset, bms, bms_list)
+	{
+		if (bms_membership(bms) == BMS_SINGLETON)
+			appendStringInfo(&buf, " %d", bms_singleton_member(bms));
+		else
+		{
+			int			x = -1;
+			bool		first = true;
+
+			appendStringInfoString(&buf, " (");
+			while ((x = bms_next_member(bms, x)) >= 0)
+			{
+				if (first)
+					first = false;
+				else
+					appendStringInfoChar(&buf, ' ');
+				appendStringInfo(&buf, "%d", x);
+			}
+			appendStringInfoChar(&buf, ')');
+		}
+	}
+
+	if (buf.len == 0)
+	{
+		ExplainPropertyText(qlabel, "none", es);
+		return;
+	}
+
+	Assert(buf.data[0] == ' ');
+	ExplainPropertyText(qlabel, buf.data + 1, es);
+	pfree(buf.data);
+}
+
 /*
  * Emit a text property describing the contents of a list of integers, OIDs,
  * or XIDs -- either a space-separated list of integer members, or the word
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 4c43fd0b19b..928b8d84ad8 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -128,8 +128,10 @@ static Path *get_cheapest_parameterized_child_path(PlannerInfo *root,
 												   Relids required_outer);
 static void accumulate_append_subpath(Path *path,
 									  List **subpaths,
-									  List **special_subpaths);
-static Path *get_singleton_append_subpath(Path *path);
+									  List **special_subpaths,
+									  List **child_append_relid_sets);
+static Path *get_singleton_append_subpath(Path *path,
+										  List **child_append_relid_sets);
 static void set_dummy_rel_pathlist(RelOptInfo *rel);
 static void set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 								  Index rti, RangeTblEntry *rte);
@@ -1406,11 +1408,15 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 {
 	List	   *subpaths = NIL;
 	bool		subpaths_valid = true;
+	List	   *subpath_cars = NIL;
 	List	   *startup_subpaths = NIL;
 	bool		startup_subpaths_valid = true;
+	List	   *startup_subpath_cars = NIL;
 	List	   *partial_subpaths = NIL;
+	List	   *partial_subpath_cars = NIL;
 	List	   *pa_partial_subpaths = NIL;
 	List	   *pa_nonpartial_subpaths = NIL;
+	List	   *pa_subpath_cars = NIL;
 	bool		partial_subpaths_valid = true;
 	bool		pa_subpaths_valid;
 	List	   *all_child_pathkeys = NIL;
@@ -1443,7 +1449,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		if (childrel->pathlist != NIL &&
 			childrel->cheapest_total_path->param_info == NULL)
 			accumulate_append_subpath(childrel->cheapest_total_path,
-									  &subpaths, NULL);
+									  &subpaths, NULL, &subpath_cars);
 		else
 			subpaths_valid = false;
 
@@ -1472,7 +1478,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 			Assert(cheapest_path->param_info == NULL);
 			accumulate_append_subpath(cheapest_path,
 									  &startup_subpaths,
-									  NULL);
+									  NULL,
+									  &startup_subpath_cars);
 		}
 		else
 			startup_subpaths_valid = false;
@@ -1483,7 +1490,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		{
 			cheapest_partial_path = linitial(childrel->partial_pathlist);
 			accumulate_append_subpath(cheapest_partial_path,
-									  &partial_subpaths, NULL);
+									  &partial_subpaths, NULL,
+									  &partial_subpath_cars);
 		}
 		else
 			partial_subpaths_valid = false;
@@ -1512,7 +1520,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				Assert(cheapest_partial_path != NULL);
 				accumulate_append_subpath(cheapest_partial_path,
 										  &pa_partial_subpaths,
-										  &pa_nonpartial_subpaths);
+										  &pa_nonpartial_subpaths,
+										  &pa_subpath_cars);
 			}
 			else
 			{
@@ -1531,7 +1540,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				 */
 				accumulate_append_subpath(nppath,
 										  &pa_nonpartial_subpaths,
-										  NULL);
+										  NULL,
+										  &pa_subpath_cars);
 			}
 		}
 
@@ -1606,14 +1616,16 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 	 * if we have zero or one live subpath due to constraint exclusion.)
 	 */
 	if (subpaths_valid)
-		add_path(rel, (Path *) create_append_path(root, rel, subpaths, NIL,
+		add_path(rel, (Path *) create_append_path(root, rel, subpaths,
+												  NIL, subpath_cars,
 												  NIL, NULL, 0, false,
 												  -1));
 
 	/* build an AppendPath for the cheap startup paths, if valid */
 	if (startup_subpaths_valid)
 		add_path(rel, (Path *) create_append_path(root, rel, startup_subpaths,
-												  NIL, NIL, NULL, 0, false, -1));
+												  NIL, startup_subpath_cars,
+												  NIL, NULL, 0, false, -1));
 
 	/*
 	 * Consider an append of unordered, unparameterized partial paths.  Make
@@ -1654,6 +1666,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Generate a partial append path. */
 		appendpath = create_append_path(root, rel, NIL, partial_subpaths,
+										partial_subpath_cars,
 										NIL, NULL, parallel_workers,
 										enable_parallel_append,
 										-1);
@@ -1704,6 +1717,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		appendpath = create_append_path(root, rel, pa_nonpartial_subpaths,
 										pa_partial_subpaths,
+										pa_subpath_cars,
 										NIL, NULL, parallel_workers, true,
 										partial_rows);
 		add_partial_path(rel, (Path *) appendpath);
@@ -1737,6 +1751,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Select the child paths for an Append with this parameterization */
 		subpaths = NIL;
+		subpath_cars = NIL;
 		subpaths_valid = true;
 		foreach(lcr, live_childrels)
 		{
@@ -1759,12 +1774,13 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				subpaths_valid = false;
 				break;
 			}
-			accumulate_append_subpath(subpath, &subpaths, NULL);
+			accumulate_append_subpath(subpath, &subpaths, NULL,
+									  &subpath_cars);
 		}
 
 		if (subpaths_valid)
 			add_path(rel, (Path *)
-					 create_append_path(root, rel, subpaths, NIL,
+					 create_append_path(root, rel, subpaths, NIL, subpath_cars,
 										NIL, required_outer, 0, false,
 										-1));
 	}
@@ -1791,6 +1807,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				continue;
 
 			appendpath = create_append_path(root, rel, NIL, list_make1(path),
+											list_make1(rel->relids),
 											NIL, NULL,
 											path->parallel_workers, true,
 											partial_rows);
@@ -1874,8 +1891,11 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 	{
 		List	   *pathkeys = (List *) lfirst(lcp);
 		List	   *startup_subpaths = NIL;
+		List	   *startup_subpath_cars = NIL;
 		List	   *total_subpaths = NIL;
+		List	   *total_subpath_cars = NIL;
 		List	   *fractional_subpaths = NIL;
+		List	   *fractional_subpath_cars = NIL;
 		bool		startup_neq_total = false;
 		bool		fraction_neq_total = false;
 		bool		match_partition_order;
@@ -2038,16 +2058,23 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * just a single subpath (and hence aren't doing anything
 				 * useful).
 				 */
-				cheapest_startup = get_singleton_append_subpath(cheapest_startup);
-				cheapest_total = get_singleton_append_subpath(cheapest_total);
+				cheapest_startup =
+					get_singleton_append_subpath(cheapest_startup,
+												 &startup_subpath_cars);
+				cheapest_total =
+					get_singleton_append_subpath(cheapest_total,
+												 &total_subpath_cars);
 
 				startup_subpaths = lappend(startup_subpaths, cheapest_startup);
 				total_subpaths = lappend(total_subpaths, cheapest_total);
 
 				if (cheapest_fractional)
 				{
-					cheapest_fractional = get_singleton_append_subpath(cheapest_fractional);
-					fractional_subpaths = lappend(fractional_subpaths, cheapest_fractional);
+					cheapest_fractional =
+						get_singleton_append_subpath(cheapest_fractional,
+													 &fractional_subpath_cars);
+					fractional_subpaths =
+						lappend(fractional_subpaths, cheapest_fractional);
 				}
 			}
 			else
@@ -2057,13 +2084,16 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * child paths for the MergeAppend.
 				 */
 				accumulate_append_subpath(cheapest_startup,
-										  &startup_subpaths, NULL);
+										  &startup_subpaths, NULL,
+										  &startup_subpath_cars);
 				accumulate_append_subpath(cheapest_total,
-										  &total_subpaths, NULL);
+										  &total_subpaths, NULL,
+										  &total_subpath_cars);
 
 				if (cheapest_fractional)
 					accumulate_append_subpath(cheapest_fractional,
-											  &fractional_subpaths, NULL);
+											  &fractional_subpaths, NULL,
+											  &fractional_subpath_cars);
 			}
 		}
 
@@ -2075,6 +2105,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 													  rel,
 													  startup_subpaths,
 													  NIL,
+													  startup_subpath_cars,
 													  pathkeys,
 													  NULL,
 													  0,
@@ -2085,6 +2116,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  total_subpaths,
 														  NIL,
+														  total_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2096,6 +2128,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  fractional_subpaths,
 														  NIL,
+														  fractional_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2108,12 +2141,14 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 			add_path(rel, (Path *) create_merge_append_path(root,
 															rel,
 															startup_subpaths,
+															startup_subpath_cars,
 															pathkeys,
 															NULL));
 			if (startup_neq_total)
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																total_subpaths,
+																total_subpath_cars,
 																pathkeys,
 																NULL));
 
@@ -2121,6 +2156,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																fractional_subpaths,
+																fractional_subpath_cars,
 																pathkeys,
 																NULL));
 		}
@@ -2223,7 +2259,8 @@ get_cheapest_parameterized_child_path(PlannerInfo *root, RelOptInfo *rel,
  * paths).
  */
 static void
-accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
+accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
+						  List **child_append_relid_sets)
 {
 	if (IsA(path, AppendPath))
 	{
@@ -2232,6 +2269,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		if (!apath->path.parallel_aware || apath->first_partial_path == 0)
 		{
 			*subpaths = list_concat(*subpaths, apath->subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 		else if (special_subpaths != NULL)
@@ -2246,6 +2285,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 												  apath->first_partial_path);
 			*special_subpaths = list_concat(*special_subpaths,
 											new_special_subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 	}
@@ -2254,6 +2295,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		*subpaths = list_concat(*subpaths, mpath->subpaths);
+		*child_append_relid_sets =
+			lappend(*child_append_relid_sets, path->parent->relids);
 		return;
 	}
 
@@ -2265,10 +2308,15 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
  *		Returns the single subpath of an Append/MergeAppend, or just
  *		return 'path' if it's not a single sub-path Append/MergeAppend.
  *
+ * As a side effect, whenever we return a single subpath rather than the
+ * original path, add the relid set for the original path to
+ * child_append_relid_sets, so that those relids don't entirely disappear
+ * from the final plan.
+ *
  * Note: 'path' must not be a parallel-aware path.
  */
 static Path *
-get_singleton_append_subpath(Path *path)
+get_singleton_append_subpath(Path *path, List **child_append_relid_sets)
 {
 	Assert(!path->parallel_aware);
 
@@ -2277,14 +2325,22 @@ get_singleton_append_subpath(Path *path)
 		AppendPath *apath = (AppendPath *) path;
 
 		if (list_length(apath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(apath->subpaths);
+		}
 	}
 	else if (IsA(path, MergeAppendPath))
 	{
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		if (list_length(mpath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(mpath->subpaths);
+		}
 	}
 
 	return path;
@@ -2313,7 +2369,7 @@ set_dummy_rel_pathlist(RelOptInfo *rel)
 	rel->partial_pathlist = NIL;
 
 	/* Set up the dummy path */
-	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
+	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL, NIL,
 											  NIL, rel->lateral_relids,
 											  0, false, -1));
 
diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 5d1fc3899da..c1ed0d3870f 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -1530,7 +1530,7 @@ mark_dummy_rel(RelOptInfo *rel)
 
 	/* Set up the dummy path */
 	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
-											  NIL, rel->lateral_relids,
+											  NIL, NIL, rel->lateral_relids,
 											  0, false, -1));
 
 	/* Set or update cheapest_total_path and related fields */
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 8af091ba647..88b4c5901b0 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -1265,6 +1265,7 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags)
 	plan->plan.lefttree = NULL;
 	plan->plan.righttree = NULL;
 	plan->apprelids = rel->relids;
+	plan->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	if (pathkeys != NIL)
 	{
@@ -1477,6 +1478,7 @@ create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path,
 	plan->lefttree = NULL;
 	plan->righttree = NULL;
 	node->apprelids = rel->relids;
+	node->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	/*
 	 * Compute sort column info, and adjust MergeAppend's tlist as needed.
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 9d5262651e7..eb62794aecd 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4027,6 +4027,7 @@ create_degenerate_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
 							   paths,
 							   NIL,
 							   NIL,
+							   NIL,
 							   NULL,
 							   0,
 							   false,
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index f528f096a56..ca2258e44d1 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -843,7 +843,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 	 * union child.
 	 */
 	apath = (Path *) create_append_path(root, result_rel, cheapest_pathlist,
-										NIL, NIL, NULL, 0, false, -1);
+										NIL, NIL, NIL, NULL, 0, false, -1);
 
 	/*
 	 * Estimate number of groups.  For now we just assume the output is unique
@@ -889,7 +889,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 
 		papath = (Path *)
 			create_append_path(root, result_rel, NIL, partial_pathlist,
-							   NIL, NULL, parallel_workers,
+							   NIL, NIL, NULL, parallel_workers,
 							   enable_parallel_append, -1);
 		gpath = (Path *)
 			create_gather_path(root, result_rel, papath,
@@ -1018,6 +1018,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 			path = (Path *) create_merge_append_path(root,
 													 result_rel,
 													 ordered_pathlist,
+													 NIL,
 													 union_pathkeys,
 													 NULL);
 
@@ -1224,8 +1225,10 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root,
 				 * between the set op targetlist and the targetlist of the
 				 * left input.  The Append will be removed in setrefs.c.
 				 */
-				apath = (Path *) create_append_path(root, result_rel, list_make1(lpath),
-													NIL, NIL, NULL, 0, false, -1);
+				apath = (Path *) create_append_path(root, result_rel,
+													list_make1(lpath),
+													NIL, NIL, NIL, NULL, 0,
+													false, -1);
 
 				add_path(result_rel, apath);
 
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index e4fd6950fad..c0a9811b130 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1300,6 +1300,7 @@ AppendPath *
 create_append_path(PlannerInfo *root,
 				   RelOptInfo *rel,
 				   List *subpaths, List *partial_subpaths,
+				   List *child_append_relid_sets,
 				   List *pathkeys, Relids required_outer,
 				   int parallel_workers, bool parallel_aware,
 				   double rows)
@@ -1309,6 +1310,7 @@ create_append_path(PlannerInfo *root,
 
 	Assert(!parallel_aware || parallel_workers > 0);
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_Append;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -1471,6 +1473,7 @@ MergeAppendPath *
 create_merge_append_path(PlannerInfo *root,
 						 RelOptInfo *rel,
 						 List *subpaths,
+						 List *child_append_relid_sets,
 						 List *pathkeys,
 						 Relids required_outer)
 {
@@ -1486,6 +1489,7 @@ create_merge_append_path(PlannerInfo *root,
 	 */
 	Assert(bms_is_empty(rel->lateral_relids) && bms_is_empty(required_outer));
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_MergeAppend;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -3950,6 +3954,7 @@ reparameterize_path(PlannerInfo *root, Path *path,
 				}
 				return (Path *)
 					create_append_path(root, rel, childpaths, partialpaths,
+									   apath->child_append_relid_sets,
 									   apath->path.pathkeys, required_outer,
 									   apath->path.parallel_workers,
 									   apath->path.parallel_aware,
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index cf3a16b8b0e..75a70489e5a 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2171,6 +2171,12 @@ typedef struct CustomPath
  * For partial Append, 'subpaths' contains non-partial subpaths followed by
  * partial subpaths.
  *
+ * Whenever accumulate_append_subpath() allows us to consolidate multiple
+ * levels of Append paths are consolidated down to one, we store the RTI
+ * sets for the omitted paths in child_append_relid_sets. This is not necessary
+ * for planning or execution; we do it for the benefit of code that wants
+ * to inspect the final plan and understand how it came to be.
+ *
  * Note: it is possible for "subpaths" to contain only one, or even no,
  * elements.  These cases are optimized during create_append_plan.
  * In particular, an AppendPath with no subpaths is a "dummy" path that
@@ -2186,6 +2192,7 @@ typedef struct AppendPath
 	/* Index of first partial path in subpaths; list_length(subpaths) if none */
 	int			first_partial_path;
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } AppendPath;
 
 #define IS_DUMMY_APPEND(p) \
@@ -2202,12 +2209,15 @@ extern bool is_dummy_rel(RelOptInfo *rel);
 /*
  * MergeAppendPath represents a MergeAppend plan, ie, the merging of sorted
  * results from several member plans to produce similarly-sorted output.
+ *
+ * child_append_relid_sets has the same meaning here as for AppendPath.
  */
 typedef struct MergeAppendPath
 {
 	Path		path;
 	List	   *subpaths;		/* list of component Paths */
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } MergeAppendPath;
 
 /*
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 5d0520d5e58..045b7ee84a7 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -394,9 +394,16 @@ struct PartitionPruneInfo;		/* forward reference to struct below */
 typedef struct Append
 {
 	Plan		plan;
+
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
+
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *appendplans;
+
 	/* # of asynchronous plans */
 	int			nasyncplans;
 
@@ -426,6 +433,10 @@ typedef struct MergeAppend
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
 
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *mergeplans;
 
 	/* these fields are just like the sort-key info in struct Sort: */
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 955e9056858..4437248cb67 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -70,12 +70,14 @@ extern TidRangePath *create_tidrangescan_path(PlannerInfo *root,
 											  Relids required_outer);
 extern AppendPath *create_append_path(PlannerInfo *root, RelOptInfo *rel,
 									  List *subpaths, List *partial_subpaths,
+									  List *child_append_relid_sets,
 									  List *pathkeys, Relids required_outer,
 									  int parallel_workers, bool parallel_aware,
 									  double rows);
 extern MergeAppendPath *create_merge_append_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 List *subpaths,
+												 List *child_append_relid_sets,
 												 List *pathkeys,
 												 Relids required_outer);
 extern GroupResultPath *create_group_result_path(PlannerInfo *root,
-- 
2.51.0



  [application/octet-stream] v4-0002-Store-information-about-elided-nodes-in-the-final.patch (9.3K, 4-v4-0002-Store-information-about-elided-nodes-in-the-final.patch)
  download | inline diff:
From f43770522736fd0b36b72acddb2c7918ac43b43e Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:42 -0400
Subject: [PATCH v4 2/6] Store information about elided nodes in the final
 plan.

An extension (or core code) might want to reconstruct the planner's
choice of join order from the final plan. To do so, it must be possible
to find all of the RTIs that were part of the join problem in that plan.
The previous commit, together with the earlier work in
8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0, is enough to let us match up
RTIs we see in the final plan with RTIs that we see during the planning
cycle, but we still have a problem if the planner decides to drop some
RTIs out of the final plan altogether.

To fix that, when setrefs.c removes a SubqueryScan, single-child Append,
or single-child MergeAppend from the final Plan tree, record the type of
the removed node and the RTIs that the removed node would have scanned
in the final plan tree. It would be natural to record this information
on the child of the removed plan node, but that would require adding
an additional pointer field to type Plan, which seems undesirable.
So, instead, store the information in a separate list that the
executor need never consult, and use the plan_node_id to identify
the plan node with which the removed node is logically associated.

Also, update pg_overexplain to display these details.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 39 ++++++++++++++
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/plan/setrefs.c          | 52 ++++++++++++++++++-
 src/include/nodes/pathnodes.h                 |  3 ++
 src/include/nodes/plannodes.h                 | 17 ++++++
 src/tools/pgindent/typedefs.list              |  1 +
 7 files changed, 114 insertions(+), 3 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 55d34666d87..ca9a23ea61f 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -452,6 +452,8 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
  Seq Scan on daucus vegetables
    Filter: (genus = 'daucus'::text)
    Scan RTI: 2
+   Elided Node Type: Append
+   Elided Node RTIs: 1
  RTI 1 (relation, inherited, in-from-clause):
    Eref: vegetables (id, name, genus)
    Relation: vegetables
@@ -465,7 +467,7 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 2
-(16 rows)
+(18 rows)
 
 -- Also test a case that involves a write.
 EXPLAIN (RANGE_TABLE, COSTS OFF)
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index 5dc707d69e3..fa907fa472e 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -191,6 +191,8 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 	 */
 	if (options->range_table)
 	{
+		bool		opened_elided_nodes = false;
+
 		switch (nodeTag(plan))
 		{
 			case T_SeqScan:
@@ -251,6 +253,43 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 			default:
 				break;
 		}
+
+		foreach_node(ElidedNode, n, es->pstmt->elidedNodes)
+		{
+			char	   *elidednodetag;
+
+			if (n->plan_node_id != plan->plan_node_id)
+				continue;
+
+			if (!opened_elided_nodes)
+			{
+				ExplainOpenGroup("Elided Nodes", "Elided Nodes", false, es);
+				opened_elided_nodes = true;
+			}
+
+			switch (n->elided_type)
+			{
+				case T_Append:
+					elidednodetag = "Append";
+					break;
+				case T_MergeAppend:
+					elidednodetag = "MergeAppend";
+					break;
+				case T_SubqueryScan:
+					elidednodetag = "SubqueryScan";
+					break;
+				default:
+					elidednodetag = psprintf("%d", n->elided_type);
+					break;
+			}
+
+			ExplainOpenGroup("Elided Node", NULL, true, es);
+			ExplainPropertyText("Elided Node Type", elidednodetag, es);
+			overexplain_bitmapset("Elided Node RTIs", n->relids, es);
+			ExplainCloseGroup("Elided Node", NULL, true, es);
+		}
+		if (opened_elided_nodes)
+			ExplainCloseGroup("Elided Nodes", "Elided Nodes", false, es);
 	}
 }
 
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 0e6b3f60f31..9d5262651e7 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -618,6 +618,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->paramExecTypes = glob->paramExecTypes;
 	/* utilityStmt should be null, but we might as well copy it */
 	result->utilityStmt = parse->utilityStmt;
+	result->elidedNodes = glob->elidedNodes;
 	result->stmt_location = parse->stmt_location;
 	result->stmt_len = parse->stmt_len;
 
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index adabae09a23..23a00d452b7 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -211,6 +211,9 @@ static List *set_windowagg_runcondition_references(PlannerInfo *root,
 												   List *runcondition,
 												   Plan *plan);
 
+static void record_elided_node(PlannerGlobal *glob, int plan_node_id,
+							   NodeTag elided_type, Bitmapset *relids);
+
 
 /*****************************************************************************
  *
@@ -1460,10 +1463,17 @@ set_subqueryscan_references(PlannerInfo *root,
 
 	if (trivial_subqueryscan(plan))
 	{
+		Index		scanrelid;
+
 		/*
 		 * We can omit the SubqueryScan node and just pull up the subplan.
 		 */
 		result = clean_up_removed_plan_level((Plan *) plan, plan->subplan);
+
+		/* Remember that we removed a SubqueryScan */
+		scanrelid = plan->scan.scanrelid + rtoffset;
+		record_elided_node(root->glob, plan->subplan->plan_node_id,
+						   T_SubqueryScan, bms_make_singleton(scanrelid));
 	}
 	else
 	{
@@ -1891,7 +1901,17 @@ set_append_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(aplan->appendplans);
 
 		if (p->parallel_aware == aplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) aplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) aplan, p);
+
+			/* Remember that we removed an Append */
+			record_elided_node(root->glob, p->plan_node_id, T_Append,
+							   offset_relid_set(aplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -1959,7 +1979,17 @@ set_mergeappend_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(mplan->mergeplans);
 
 		if (p->parallel_aware == mplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) mplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) mplan, p);
+
+			/* Remember that we removed a MergeAppend */
+			record_elided_node(root->glob, p->plan_node_id, T_MergeAppend,
+							   offset_relid_set(mplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -3774,3 +3804,21 @@ extract_query_dependencies_walker(Node *node, PlannerInfo *context)
 	return expression_tree_walker(node, extract_query_dependencies_walker,
 								  context);
 }
+
+/*
+ * Record some details about a node removed from the plan during setrefs
+ * procesing, for the benefit of code trying to reconstruct planner decisions
+ * from examination of the final plan tree.
+ */
+static void
+record_elided_node(PlannerGlobal *glob, int plan_node_id,
+				   NodeTag elided_type, Bitmapset *relids)
+{
+	ElidedNode *n = makeNode(ElidedNode);
+
+	n->plan_node_id = plan_node_id;
+	n->elided_type = elided_type;
+	n->relids = relids;
+
+	glob->elidedNodes = lappend(glob->elidedNodes, n);
+}
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index a3a800869df..cf3a16b8b0e 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -159,6 +159,9 @@ typedef struct PlannerGlobal
 	/* type OIDs for PARAM_EXEC Params */
 	List	   *paramExecTypes;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/* highest PlaceHolderVar ID assigned */
 	Index		lastPHId;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 1526dd2ec6b..5d0520d5e58 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -152,6 +152,9 @@ typedef struct PlannedStmt
 	/* non-null if this is utility stmt */
 	Node	   *utilityStmt;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/*
 	 * DefElem objects added by extensions, e.g. using planner_shutdown_hook
 	 *
@@ -1838,4 +1841,18 @@ typedef struct SubPlanRTInfo
 	bool		dummy;
 } SubPlanRTInfo;
 
+/*
+ * ElidedNode
+ *
+ * Information about nodes elided from the final plan tree: trivial subquery
+ * scans, and single-child Append and MergeAppend nodes.
+ */
+typedef struct ElidedNode
+{
+	NodeTag		type;
+	int			plan_node_id;
+	NodeTag		elided_type;
+	Bitmapset  *relids;
+} ElidedNode;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9da8ad99a20..6c82aa9511e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -698,6 +698,7 @@ EachState
 Edge
 EditableObjectType
 ElementsState
+ElidedNode
 EnableTimeoutParams
 EndDataPtrType
 EndDirectModify_function
-- 
2.51.0



  [application/octet-stream] v4-0004-Temporary-hack-to-unbreak-partitionwise-join-cont.patch (15.2K, 5-v4-0004-Temporary-hack-to-unbreak-partitionwise-join-cont.patch)
  download | inline diff:
From 744b99293955e0a138134db22ddf4d30ef63b1f3 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Wed, 29 Oct 2025 15:17:46 -0400
Subject: [PATCH v4 4/6] Temporary hack to unbreak partitionwise join control.

Resetting the pathlist and partial pathlist to NIL when the
topmost scan/join rel is a partitioned joinrel is incorrect. The issue
was originally reported by Ashutosh Bapat here:

http://postgr.es/m/CAExHW5toze58+jL-454J3ty11sqJyU13Sz5rJPQZDmASwZgWiA@mail.gmail.com

I failed to understand Ashutosh's explanation until I hit the problem
myself, so here's my attempt to re-explain what he had said, just in
case you find my explanation any clearer:

http://postgr.es/m/CA%2BTgmoZvBD%2B5vyQruXBVXW74FMgWxE%3DO4K4rCrCtEELWNj-MLA%40mail.gmail.com

As subsequent discussion on that thread indicates, it is unclear
exactly what the right fix for this problem is, and at least as of
this writing, it is even more unclear how to adjust the test cases
that break. What I've done here is just accept all the changes to the
regression test outputs, which is almost certainly the wrong idea,
especially since I've also added no comments.

This is just a temporary hack to make it possible to test this patch
set, because without this, PARTITIONWISE() advice can't be used to
suppress a partitionwise join, because all of the alternatives get
eliminated regardless of cost.
---
 src/backend/optimizer/plan/planner.c         |   4 +-
 src/test/regress/expected/partition_join.out | 172 ++++++++-----------
 src/test/regress/expected/subselect.out      |  41 ++---
 3 files changed, 91 insertions(+), 126 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index eb62794aecd..8b1ab847f39 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -7927,7 +7927,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	 * generate_useful_gather_paths to add path(s) to the main list, and
 	 * finally zap the partial pathlist.
 	 */
-	if (rel_is_partitioned)
+	if (rel_is_partitioned && IS_SIMPLE_REL(rel))
 		rel->pathlist = NIL;
 
 	/*
@@ -7953,7 +7953,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root,
 	}
 
 	/* Finish dropping old paths for a partitioned rel, per comment above */
-	if (rel_is_partitioned)
+	if (rel_is_partitioned && IS_SIMPLE_REL(rel))
 		rel->partial_pathlist = NIL;
 
 	/* Extract SRF-free scan/join target. */
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index 713828be335..3e34f05ba62 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -65,31 +65,24 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
 -- inner join with partially-redundant join clauses
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
-                          QUERY PLAN                           
----------------------------------------------------------------
- Sort
-   Sort Key: t1.a
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Merge Join
+   Merge Cond: (t1.a = t2.a)
    ->  Append
-         ->  Merge Join
-               Merge Cond: (t1_1.a = t2_1.a)
-               ->  Index Scan using iprt1_p1_a on prt1_p1 t1_1
-               ->  Sort
-                     Sort Key: t2_1.b
-                     ->  Seq Scan on prt2_p1 t2_1
-                           Filter: (a = b)
-         ->  Hash Join
-               Hash Cond: (t1_2.a = t2_2.a)
-               ->  Seq Scan on prt1_p2 t1_2
-               ->  Hash
-                     ->  Seq Scan on prt2_p2 t2_2
-                           Filter: (a = b)
-         ->  Hash Join
-               Hash Cond: (t1_3.a = t2_3.a)
-               ->  Seq Scan on prt1_p3 t1_3
-               ->  Hash
-                     ->  Seq Scan on prt2_p3 t2_3
-                           Filter: (a = b)
-(22 rows)
+         ->  Index Scan using iprt1_p1_a on prt1_p1 t1_1
+         ->  Index Scan using iprt1_p2_a on prt1_p2 t1_2
+         ->  Index Scan using iprt1_p3_a on prt1_p3 t1_3
+   ->  Sort
+         Sort Key: t2.b
+         ->  Append
+               ->  Seq Scan on prt2_p1 t2_1
+                     Filter: (a = b)
+               ->  Seq Scan on prt2_p2 t2_2
+                     Filter: (a = b)
+               ->  Seq Scan on prt2_p3 t2_3
+                     Filter: (a = b)
+(15 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.a AND t1.a = t2.b ORDER BY t1.a, t2.b;
  a  |  c   | b  |  c   
@@ -1249,56 +1242,50 @@ SET enable_hashjoin TO off;
 SET enable_nestloop TO off;
 EXPLAIN (COSTS OFF)
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
-                            QUERY PLAN                            
-------------------------------------------------------------------
- Merge Append
-   Sort Key: t1.a
-   ->  Merge Semi Join
-         Merge Cond: (t1_3.a = t1_6.b)
-         ->  Sort
-               Sort Key: t1_3.a
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Merge Join
+   Merge Cond: (t1.a = t1_1.b)
+   ->  Sort
+         Sort Key: t1.a
+         ->  Append
                ->  Seq Scan on prt1_p1 t1_3
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_6.b = (((t1_9.a + t1_9.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_6.b
-                     ->  Seq Scan on prt2_p1 t1_6
-               ->  Sort
-                     Sort Key: (((t1_9.a + t1_9.b) / 2))
-                     ->  Seq Scan on prt1_e_p1 t1_9
-                           Filter: (c = 0)
-   ->  Merge Semi Join
-         Merge Cond: (t1_4.a = t1_7.b)
-         ->  Sort
-               Sort Key: t1_4.a
                ->  Seq Scan on prt1_p2 t1_4
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_7.b = (((t1_10.a + t1_10.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_7.b
-                     ->  Seq Scan on prt2_p2 t1_7
-               ->  Sort
-                     Sort Key: (((t1_10.a + t1_10.b) / 2))
-                     ->  Seq Scan on prt1_e_p2 t1_10
-                           Filter: (c = 0)
-   ->  Merge Semi Join
-         Merge Cond: (t1_5.a = t1_8.b)
-         ->  Sort
-               Sort Key: t1_5.a
                ->  Seq Scan on prt1_p3 t1_5
                      Filter: (b = 0)
-         ->  Merge Semi Join
-               Merge Cond: (t1_8.b = (((t1_11.a + t1_11.b) / 2)))
-               ->  Sort
-                     Sort Key: t1_8.b
-                     ->  Seq Scan on prt2_p3 t1_8
-               ->  Sort
-                     Sort Key: (((t1_11.a + t1_11.b) / 2))
-                     ->  Seq Scan on prt1_e_p3 t1_11
-                           Filter: (c = 0)
-(47 rows)
+   ->  Unique
+         ->  Merge Append
+               Sort Key: t1_1.b
+               ->  Merge Semi Join
+                     Merge Cond: (t1_6.b = (((t1_9.a + t1_9.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_6.b
+                           ->  Seq Scan on prt2_p1 t1_6
+                     ->  Sort
+                           Sort Key: (((t1_9.a + t1_9.b) / 2))
+                           ->  Seq Scan on prt1_e_p1 t1_9
+                                 Filter: (c = 0)
+               ->  Merge Semi Join
+                     Merge Cond: (t1_7.b = (((t1_10.a + t1_10.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_7.b
+                           ->  Seq Scan on prt2_p2 t1_7
+                     ->  Sort
+                           Sort Key: (((t1_10.a + t1_10.b) / 2))
+                           ->  Seq Scan on prt1_e_p2 t1_10
+                                 Filter: (c = 0)
+               ->  Merge Semi Join
+                     Merge Cond: (t1_8.b = (((t1_11.a + t1_11.b) / 2)))
+                     ->  Sort
+                           Sort Key: t1_8.b
+                           ->  Seq Scan on prt2_p3 t1_8
+                     ->  Sort
+                           Sort Key: (((t1_11.a + t1_11.b) / 2))
+                           ->  Seq Scan on prt1_e_p3 t1_11
+                                 Filter: (c = 0)
+(41 rows)
 
 SELECT t1.* FROM prt1 t1 WHERE t1.a IN (SELECT t1.b FROM prt2 t1 WHERE t1.b IN (SELECT (t1.a + t1.b)/2 FROM prt1_e t1 WHERE t1.c = 0)) AND t1.b = 0 ORDER BY t1.a;
   a  | b |  c   
@@ -4923,32 +4910,27 @@ ANALYZE plt3_adv;
 -- '0001' of that partition
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM (plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.c = t2.c)) FULL JOIN plt3_adv t3 ON (t1.c = t3.c) WHERE coalesce(t1.a, 0) % 5 != 3 AND coalesce(t1.a, 0) % 5 != 4 ORDER BY t1.c, t1.a, t2.a, t3.a;
-                                          QUERY PLAN                                           
------------------------------------------------------------------------------------------------
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
  Sort
    Sort Key: t1.c, t1.a, t2.a, t3.a
-   ->  Append
-         ->  Hash Full Join
-               Hash Cond: (t1_1.c = t3_1.c)
-               Filter: (((COALESCE(t1_1.a, 0) % 5) <> 3) AND ((COALESCE(t1_1.a, 0) % 5) <> 4))
-               ->  Hash Left Join
-                     Hash Cond: (t1_1.c = t2_1.c)
+   ->  Hash Full Join
+         Hash Cond: (t1.c = t3.c)
+         Filter: (((COALESCE(t1.a, 0) % 5) <> 3) AND ((COALESCE(t1.a, 0) % 5) <> 4))
+         ->  Hash Left Join
+               Hash Cond: (t1.c = t2.c)
+               ->  Append
                      ->  Seq Scan on plt1_adv_p1 t1_1
-                     ->  Hash
-                           ->  Seq Scan on plt2_adv_p1 t2_1
-               ->  Hash
-                     ->  Seq Scan on plt3_adv_p1 t3_1
-         ->  Hash Full Join
-               Hash Cond: (t1_2.c = t3_2.c)
-               Filter: (((COALESCE(t1_2.a, 0) % 5) <> 3) AND ((COALESCE(t1_2.a, 0) % 5) <> 4))
-               ->  Hash Left Join
-                     Hash Cond: (t1_2.c = t2_2.c)
                      ->  Seq Scan on plt1_adv_p2 t1_2
-                     ->  Hash
-                           ->  Seq Scan on plt2_adv_p2 t2_2
                ->  Hash
+                     ->  Append
+                           ->  Seq Scan on plt2_adv_p1 t2_1
+                           ->  Seq Scan on plt2_adv_p2 t2_2
+         ->  Hash
+               ->  Append
+                     ->  Seq Scan on plt3_adv_p1 t3_1
                      ->  Seq Scan on plt3_adv_p2 t3_2
-(23 rows)
+(18 rows)
 
 SELECT t1.a, t1.c, t2.a, t2.c, t3.a, t3.c FROM (plt1_adv t1 LEFT JOIN plt2_adv t2 ON (t1.c = t2.c)) FULL JOIN plt3_adv t3 ON (t1.c = t3.c) WHERE coalesce(t1.a, 0) % 5 != 3 AND coalesce(t1.a, 0) % 5 != 4 ORDER BY t1.c, t1.a, t2.a, t3.a;
  a  |  c   | a  |  c   | a  |  c   
@@ -5240,17 +5222,15 @@ SELECT x.id, y.id FROM fract_t x LEFT JOIN fract_t y USING (id) ORDER BY x.id AS
                               QUERY PLAN                               
 -----------------------------------------------------------------------
  Limit
-   ->  Merge Append
-         Sort Key: x.id
-         ->  Merge Left Join
-               Merge Cond: (x_1.id = y_1.id)
+   ->  Merge Left Join
+         Merge Cond: (x.id = y.id)
+         ->  Append
                ->  Index Only Scan using fract_t0_pkey on fract_t0 x_1
-               ->  Index Only Scan using fract_t0_pkey on fract_t0 y_1
-         ->  Merge Left Join
-               Merge Cond: (x_2.id = y_2.id)
                ->  Index Only Scan using fract_t1_pkey on fract_t1 x_2
+         ->  Append
+               ->  Index Only Scan using fract_t0_pkey on fract_t0 y_1
                ->  Index Only Scan using fract_t1_pkey on fract_t1 y_2
-(11 rows)
+(9 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT x.id, y.id FROM fract_t x LEFT JOIN fract_t y USING (id) ORDER BY x.id DESC LIMIT 10;
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index cf6b32d1173..8549601e3bc 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -850,10 +850,11 @@ where (t1.a, t2.a) in (select a, a from unique_tbl_p t3)
 order by t1.a, t2.a;
                                            QUERY PLAN                                           
 ------------------------------------------------------------------------------------------------
- Merge Append
-   Sort Key: t1.a
-   ->  Nested Loop
-         Output: t1_1.a, t1_1.b, t2_1.a, t2_1.b
+ Merge Join
+   Output: t1.a, t1.b, t2.a, t2.b
+   Merge Cond: (t1.a = t2.a)
+   ->  Merge Append
+         Sort Key: t1.a
          ->  Nested Loop
                Output: t1_1.a, t1_1.b, t3_1.a
                ->  Unique
@@ -863,15 +864,6 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t1_1
                      Output: t1_1.a, t1_1.b
                      Index Cond: (t1_1.a = t3_1.a)
-         ->  Memoize
-               Output: t2_1.a, t2_1.b
-               Cache Key: t1_1.a
-               Cache Mode: logical
-               ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t2_1
-                     Output: t2_1.a, t2_1.b
-                     Index Cond: (t2_1.a = t1_1.a)
-   ->  Nested Loop
-         Output: t1_2.a, t1_2.b, t2_2.a, t2_2.b
          ->  Nested Loop
                Output: t1_2.a, t1_2.b, t3_2.a
                ->  Unique
@@ -881,15 +873,6 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t1_2
                      Output: t1_2.a, t1_2.b
                      Index Cond: (t1_2.a = t3_2.a)
-         ->  Memoize
-               Output: t2_2.a, t2_2.b
-               Cache Key: t1_2.a
-               Cache Mode: logical
-               ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t2_2
-                     Output: t2_2.a, t2_2.b
-                     Index Cond: (t2_2.a = t1_2.a)
-   ->  Nested Loop
-         Output: t1_3.a, t1_3.b, t2_3.a, t2_3.b
          ->  Nested Loop
                Output: t1_3.a, t1_3.b, t3_3.a
                ->  Unique
@@ -902,14 +885,16 @@ order by t1.a, t2.a;
                ->  Index Scan using unique_tbl_p3_a_idx on public.unique_tbl_p3 t1_3
                      Output: t1_3.a, t1_3.b
                      Index Cond: (t1_3.a = t3_3.a)
-         ->  Memoize
-               Output: t2_3.a, t2_3.b
-               Cache Key: t1_3.a
-               Cache Mode: logical
+   ->  Materialize
+         Output: t2.a, t2.b
+         ->  Append
+               ->  Index Scan using unique_tbl_p1_a_idx on public.unique_tbl_p1 t2_1
+                     Output: t2_1.a, t2_1.b
+               ->  Index Scan using unique_tbl_p2_a_idx on public.unique_tbl_p2 t2_2
+                     Output: t2_2.a, t2_2.b
                ->  Index Scan using unique_tbl_p3_a_idx on public.unique_tbl_p3 t2_3
                      Output: t2_3.a, t2_3.b
-                     Index Cond: (t2_3.a = t1_3.a)
-(59 rows)
+(44 rows)
 
 reset enable_partitionwise_join;
 drop table unique_tbl_p;
-- 
2.51.0



  [application/octet-stream] v4-0005-Allow-for-plugin-control-over-path-generation-str.patch (55.7K, 6-v4-0005-Allow-for-plugin-control-over-path-generation-str.patch)
  download | inline diff:
From 4fcf512886b6d0d0dc63c230ef055a28015857e6 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 24 Oct 2025 15:11:47 -0400
Subject: [PATCH v4 5/6] Allow for plugin control over path generation
 strategies.

Each RelOptInfo now has a pgs_mask member which is a mask of acceptable
strategies. For most rels, this is populated from PlannerGlobal's
default_pgs_mask, which is computed from the values of the enable_*
GUCs at the start of planning.

For baserels, get_relation_info_hook can be used to adjust pgs_mask for
each new RelOptInfo, at least for rels of type RTE_RELATION. Adjusting
pgs_mask is less useful for other types of rels, but if it proves to
be necessary, we can revisit the way this hook works or add a new one.

For joinrels, two new hooks are added. joinrel_setup_hook is called each
time a joinrel is created, and one thing that can be done from that hook
is to manipulate pgs_mask for the new joinrel. join_path_setup_hook is
called each time we're about to add paths to a joinrel by considering
some particular combination of an outer rel, an inner rel, and a join
type. It can modify the pgs_mask propagated into JoinPathExtraData to
restrict strategy choice for that paricular combination of rels.

To make joinrel_setup_hook work as intended, the existing calls to
build_joinrel_partition_info are moved later in the calling functions;
this is because that function checks whether the rel's pgs_mask includes
PGS_CONSIDER_PARTITIONWISE, so we want it to only be called after
plugins have had a chance to alter pgs_mask.

Upper rels currently inherit pgs_mask from the input relation. It's
unclear that this is the most useful behavior, but at the moment there
are no hooks to allow the mask to be set in any other way.
---
 src/backend/optimizer/path/allpaths.c   |   2 +-
 src/backend/optimizer/path/costsize.c   | 222 ++++++++++++++++++------
 src/backend/optimizer/path/indxpath.c   |   4 +-
 src/backend/optimizer/path/joinpath.c   |  88 +++++++---
 src/backend/optimizer/path/tidpath.c    |   7 +-
 src/backend/optimizer/plan/createplan.c |   4 +-
 src/backend/optimizer/plan/planner.c    |  54 ++++++
 src/backend/optimizer/util/pathnode.c   |  19 +-
 src/backend/optimizer/util/plancat.c    |   3 +
 src/backend/optimizer/util/relnode.c    |  43 ++++-
 src/include/nodes/pathnodes.h           |  82 ++++++++-
 src/include/optimizer/cost.h            |   4 +-
 src/include/optimizer/pathnode.h        |  11 +-
 src/include/optimizer/paths.h           |   9 +-
 14 files changed, 454 insertions(+), 98 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 928b8d84ad8..8e9dde3d195 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -954,7 +954,7 @@ set_tablesample_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *
 		 bms_membership(root->all_query_rels) != BMS_SINGLETON) &&
 		!(GetTsmRoutine(rte->tablesample->tsmhandler)->repeatable_across_scans))
 	{
-		path = (Path *) create_material_path(rel, path);
+		path = (Path *) create_material_path(rel, path, true);
 	}
 
 	add_path(rel, path);
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 8335cf5b5c5..6e47c9f5893 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -275,6 +275,7 @@ cost_seqscan(Path *path, PlannerInfo *root,
 	double		spc_seq_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = PGS_SEQSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -327,8 +328,11 @@ cost_seqscan(Path *path, PlannerInfo *root,
 		 */
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	path->disabled_nodes = enable_seqscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
 }
@@ -354,6 +358,7 @@ cost_samplescan(Path *path, PlannerInfo *root,
 				spc_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations with tablesample clauses */
 	Assert(baserel->relid > 0);
@@ -401,7 +406,11 @@ cost_samplescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -440,7 +449,8 @@ cost_gather(GatherPath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows;
 
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost;
 	path->path.total_cost = (startup_cost + run_cost);
 }
@@ -506,8 +516,8 @@ cost_gather_merge(GatherMergePath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows * 1.05;
 
-	path->path.disabled_nodes = input_disabled_nodes
-		+ (enable_gathermerge ? 0 : 1);
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER_MERGE) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost + input_startup_cost;
 	path->path.total_cost = (startup_cost + run_cost + input_total_cost);
 }
@@ -557,6 +567,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	double		pages_fetched;
 	double		rand_heap_pages;
 	double		index_pages;
+	uint64		enable_mask;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo) &&
@@ -588,8 +599,11 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 											  path->indexclauses);
 	}
 
-	/* we don't need to check enable_indexonlyscan; indxpath.c does that */
-	path->path.disabled_nodes = enable_indexscan ? 0 : 1;
+	/* is this scan type disabled? */
+	enable_mask = (indexonly ? PGS_INDEXONLYSCAN : PGS_INDEXSCAN)
+		| (path->path.parallel_workers == 0 ? PGS_CONSIDER_NONPARTIAL : 0);
+	path->path.disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 
 	/*
 	 * Call index-access-method-specific code to estimate the processing cost
@@ -1010,6 +1024,7 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	double		spc_seq_page_cost,
 				spc_random_page_cost;
 	double		T;
+	uint64		enable_mask = PGS_BITMAPSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo));
@@ -1075,6 +1090,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 
 	run_cost += cpu_run_cost;
@@ -1083,7 +1100,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = enable_bitmapscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1240,6 +1258,7 @@ cost_tidscan(Path *path, PlannerInfo *root,
 	double		ntuples;
 	ListCell   *l;
 	double		spc_random_page_cost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1261,10 +1280,10 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 		/*
 		 * We must use a TID scan for CurrentOfExpr; in any other case, we
-		 * should be generating a TID scan only if enable_tidscan=true. Also,
-		 * if CurrentOfExpr is the qual, there should be only one.
+		 * should be generating a TID scan only if TID scans are allowed.
+		 * Also, if CurrentOfExpr is the qual, there should be only one.
 		 */
-		Assert(enable_tidscan || IsA(qual, CurrentOfExpr));
+		Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0 || IsA(qual, CurrentOfExpr));
 		Assert(list_length(tidquals) == 1 || !IsA(qual, CurrentOfExpr));
 
 		if (IsA(qual, ScalarArrayOpExpr))
@@ -1316,10 +1335,14 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 	/*
 	 * There are assertions above verifying that we only reach this function
-	 * either when enable_tidscan=true or when the TID scan is the only legal
-	 * path, so it's safe to set disabled_nodes to zero here.
+	 * either when baserel->pgs_mask includes PGS_TIDSCAN or when the TID scan
+	 * is the only legal path, so we only need to consider the effects of
+	 * PGS_CONSIDER_NONPARTIAL here.
 	 */
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1349,6 +1372,7 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	double		nseqpages;
 	double		spc_random_page_cost;
 	double		spc_seq_page_cost;
+	uint64		enable_mask = PGS_TIDSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1412,8 +1436,15 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	/* we should not generate this path type when enable_tidscan=false */
-	Assert(enable_tidscan);
+	/*
+	 * We should not generate this path type when PGS_TIDSCAN is unset, but we
+	 * might need to disable this path due to PGS_CONSIDER_NONPARTIAL.
+	 */
+	Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0);
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->disabled_nodes = 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
@@ -1437,6 +1468,7 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	List	   *qpquals;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are subqueries */
 	Assert(baserel->relid > 0);
@@ -1467,7 +1499,10 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	 * SubqueryScan node, plus cpu_tuple_cost to account for selection and
 	 * projection overhead.
 	 */
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	if (path->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ (((baserel->pgs_mask & enable_mask) != enable_mask) ? 1 : 0);
 	path->path.startup_cost = path->subpath->startup_cost;
 	path->path.total_cost = path->subpath->total_cost;
 
@@ -1518,6 +1553,7 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1558,7 +1594,10 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1580,6 +1619,7 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1615,7 +1655,10 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1635,6 +1678,7 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are values lists */
 	Assert(baserel->relid > 0);
@@ -1663,7 +1707,10 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1686,6 +1733,7 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are CTEs */
 	Assert(baserel->relid > 0);
@@ -1711,7 +1759,10 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1728,6 +1779,7 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are Tuplestores */
 	Assert(baserel->relid > 0);
@@ -1749,7 +1801,10 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	cpu_per_tuple += cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1766,6 +1821,7 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to RTE_RESULT base relations */
 	Assert(baserel->relid > 0);
@@ -1784,7 +1840,10 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1802,6 +1861,7 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	Cost		startup_cost;
 	Cost		total_cost;
 	double		total_rows;
+	uint64		enable_mask = 0;
 
 	/* We probably have decent estimates for the non-recursive term */
 	startup_cost = nrterm->startup_cost;
@@ -1824,7 +1884,10 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	 */
 	total_cost += cpu_tuple_cost * total_rows;
 
-	runion->disabled_nodes = nrterm->disabled_nodes + rterm->disabled_nodes;
+	if (runion->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	runion->disabled_nodes =
+		(runion->parent->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	runion->startup_cost = startup_cost;
 	runion->total_cost = total_cost;
 	runion->rows = total_rows;
@@ -2094,7 +2157,11 @@ cost_incremental_sort(Path *path,
 
 	path->rows = input_tuples;
 
-	/* should not generate these paths when enable_incremental_sort=false */
+	/*
+	 * We should not generate these paths when enable_incremental_sort=false.
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	Assert(enable_incremental_sort);
 	path->disabled_nodes = input_disabled_nodes;
 
@@ -2132,6 +2199,10 @@ cost_sort(Path *path, PlannerInfo *root,
 
 	startup_cost += input_cost;
 
+	/*
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	path->rows = tuples;
 	path->disabled_nodes = input_disabled_nodes + (enable_sort ? 0 : 1);
 	path->startup_cost = startup_cost;
@@ -2223,9 +2294,15 @@ append_nonpartial_cost(List *subpaths, int numpaths, int parallel_workers)
 void
 cost_append(AppendPath *apath, PlannerInfo *root)
 {
+	RelOptInfo *rel = apath->path.parent;
 	ListCell   *l;
+	uint64		enable_mask = PGS_APPEND;
+
+	if (apath->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	apath->path.disabled_nodes = 0;
+	apath->path.disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	apath->path.startup_cost = 0;
 	apath->path.total_cost = 0;
 	apath->path.rows = 0;
@@ -2435,11 +2512,16 @@ cost_merge_append(Path *path, PlannerInfo *root,
 				  Cost input_startup_cost, Cost input_total_cost,
 				  double tuples)
 {
+	RelOptInfo *rel = path->parent;
 	Cost		startup_cost = 0;
 	Cost		run_cost = 0;
 	Cost		comparison_cost;
 	double		N;
 	double		logN;
+	uint64		enable_mask = PGS_MERGE_APPEND;
+
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/*
 	 * Avoid log(0)...
@@ -2462,7 +2544,9 @@ cost_merge_append(Path *path, PlannerInfo *root,
 	 */
 	run_cost += cpu_tuple_cost * APPEND_CPU_COST_MULTIPLIER * tuples;
 
-	path->disabled_nodes = input_disabled_nodes;
+	path->disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
+	path->disabled_nodes += input_disabled_nodes;
 	path->startup_cost = startup_cost + input_startup_cost;
 	path->total_cost = startup_cost + run_cost + input_total_cost;
 }
@@ -2481,7 +2565,7 @@ cost_merge_append(Path *path, PlannerInfo *root,
  */
 void
 cost_material(Path *path,
-			  int input_disabled_nodes,
+			  bool enabled, int input_disabled_nodes,
 			  Cost input_startup_cost, Cost input_total_cost,
 			  double tuples, int width)
 {
@@ -2490,6 +2574,11 @@ cost_material(Path *path,
 	double		nbytes = relation_byte_size(tuples, width);
 	double		work_mem_bytes = work_mem * (Size) 1024;
 
+	if (path->parallel_workers == 0 &&
+		path->parent != NULL &&
+		(path->parent->pgs_mask & PGS_CONSIDER_NONPARTIAL) == 0)
+		enabled = false;
+
 	path->rows = tuples;
 
 	/*
@@ -2519,7 +2608,7 @@ cost_material(Path *path,
 		run_cost += seq_page_cost * npages;
 	}
 
-	path->disabled_nodes = input_disabled_nodes + (enable_material ? 0 : 1);
+	path->disabled_nodes = input_disabled_nodes + (enabled ? 0 : 1);
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -3271,7 +3360,7 @@ cost_group(Path *path, PlannerInfo *root,
  */
 void
 initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
-					  JoinType jointype,
+					  JoinType jointype, uint64 enable_mask,
 					  Path *outer_path, Path *inner_path,
 					  JoinPathExtraData *extra)
 {
@@ -3285,7 +3374,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Cost		inner_rescan_run_cost;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_nestloop ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
@@ -3685,7 +3774,19 @@ initial_cost_mergejoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Assert(outerstartsel <= outerendsel);
 	Assert(innerstartsel <= innerendsel);
 
-	disabled_nodes = enable_mergejoin ? 0 : 1;
+	/*
+	 * We don't decide whether to materialize the inner path until we get to
+	 * final_cost_mergejoin(), so we don't know whether to check the pgs_mask
+	 * again PGS_MERGEJOIN_PLAIN or PGS_MERGEJOIN_MATERIALIZE. Instead, we
+	 * just account for any child nodes here and assume that this node is not
+	 * itslef disabled; we can sort out the details in final_cost_mergejoin().
+	 *
+	 * (We could be more precise here by setting disabled_nodes to 1 at this
+	 * stage if both PGS_MERGEJOIN_PLAIN and PGS_MERGEJOIN_MATERIALIZE are
+	 * disabled, but that seems to against the idea of making this function
+	 * produce a quick, optimistic approximation of the final cost.)
+	 */
+	disabled_nodes = 0;
 
 	/* cost of source data */
 
@@ -3864,9 +3965,7 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	double		mergejointuples,
 				rescannedtuples;
 	double		rescanratio;
-
-	/* Set the number of disabled nodes. */
-	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+	uint64		enable_mask = 0;
 
 	/* Protect some assumptions below that rowcounts aren't zero */
 	if (inner_path_rows <= 0)
@@ -3996,16 +4095,20 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 		path->materialize_inner = false;
 
 	/*
-	 * Prefer materializing if it looks cheaper, unless the user has asked to
-	 * suppress materialization.
+	 * If merge joins with materialization are enabled, then choose
+	 * materialization if either (a) it looks cheaper or (b) merge joins
+	 * without materialization are disabled.
 	 */
-	else if (enable_material && mat_inner_cost < bare_inner_cost)
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 (mat_inner_cost < bare_inner_cost ||
+			  (extra->pgs_mask & PGS_MERGEJOIN_PLAIN) == 0))
 		path->materialize_inner = true;
 
 	/*
-	 * Even if materializing doesn't look cheaper, we *must* do it if the
-	 * inner path is to be used directly (without sorting) and it doesn't
-	 * support mark/restore.
+	 * Regardless of what plan shapes are enabled and what the costs seem to
+	 * be, we *must* materialize it if the inner path is to be used directly
+	 * (without sorting) and it doesn't support mark/restore. Planner failure
+	 * is not an option!
 	 *
 	 * Since the inner side must be ordered, and only Sorts and IndexScans can
 	 * create order to begin with, and they both support mark/restore, you
@@ -4013,10 +4116,6 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * merge joins can *preserve* the order of their inputs, so they can be
 	 * selected as the input of a mergejoin, and they don't support
 	 * mark/restore at present.
-	 *
-	 * We don't test the value of enable_material here, because
-	 * materialization is required for correctness in this case, and turning
-	 * it off does not entitle us to deliver an invalid plan.
 	 */
 	else if (innersortkeys == NIL &&
 			 !ExecSupportsMarkRestore(inner_path))
@@ -4030,10 +4129,11 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * though.
 	 *
 	 * Since materialization is a performance optimization in this case,
-	 * rather than necessary for correctness, we skip it if enable_material is
-	 * off.
+	 * rather than necessary for correctness, we skip it if materialization is
+	 * switched off.
 	 */
-	else if (enable_material && innersortkeys != NIL &&
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 innersortkeys != NIL &&
 			 relation_byte_size(inner_path_rows,
 								inner_path->pathtarget->width) >
 			 work_mem * (Size) 1024)
@@ -4041,11 +4141,29 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	else
 		path->materialize_inner = false;
 
-	/* Charge the right incremental cost for the chosen case */
+	/* Get the number of disabled nodes, not yet including this one. */
+	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+
+	/*
+	 * Charge the right incremental cost for the chosen case, and update
+	 * enable_mask as appropriate.
+	 */
 	if (path->materialize_inner)
+	{
 		run_cost += mat_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
 	else
+	{
 		run_cost += bare_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_PLAIN;
+	}
+
+	/* Incremental count of disabled nodes if this node is disabled. */
+	if (path->jpath.path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	if ((extra->pgs_mask & enable_mask) != enable_mask)
+		++path->jpath.path.disabled_nodes;
 
 	/* CPU costs */
 
@@ -4183,9 +4301,13 @@ initial_cost_hashjoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	int			numbatches;
 	int			num_skew_mcvs;
 	size_t		space_allowed;	/* unused */
+	uint64		enable_mask = PGS_HASHJOIN;
+
+	if (outer_path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_hashjoin ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index c62e3f87724..26979d71e0b 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -2233,8 +2233,8 @@ check_index_only(RelOptInfo *rel, IndexOptInfo *index)
 	ListCell   *lc;
 	int			i;
 
-	/* Index-only scans must be enabled */
-	if (!enable_indexonlyscan)
+	/* If we're not allowed to consider index-only scans, give up now */
+	if ((rel->pgs_mask & PGS_CONSIDER_INDEXONLY) == 0)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index ea5b6415186..388d8456ff6 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -29,8 +29,9 @@
 #include "utils/lsyscache.h"
 #include "utils/typcache.h"
 
-/* Hook for plugins to get control in add_paths_to_joinrel() */
+/* Hooks for plugins to get control in add_paths_to_joinrel() */
 set_join_pathlist_hook_type set_join_pathlist_hook = NULL;
+join_path_setup_hook_type join_path_setup_hook = NULL;
 
 /*
  * Paths parameterized by a parent rel can be considered to be parameterized
@@ -151,6 +152,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	extra.mergeclause_list = NIL;
 	extra.sjinfo = sjinfo;
 	extra.param_source_rels = NULL;
+	extra.pgs_mask = joinrel->pgs_mask;
 
 	/*
 	 * See if the inner relation is provably unique for this outer rel.
@@ -207,13 +209,38 @@ add_paths_to_joinrel(PlannerInfo *root,
 	if (jointype == JOIN_UNIQUE_OUTER || jointype == JOIN_UNIQUE_INNER)
 		jointype = JOIN_INNER;
 
+	/*
+	 * Give extensions a chance to take control. In particular, an extension
+	 * might want to modify extra.pgs_mask. It's possible to override pgs_mask
+	 * on a query-wide basis using join_search_hook, or for a particular
+	 * relation using joinrel_setup_hook, but extensions that want to provide
+	 * different advice for the same joinrel based on the choice of innerrel
+	 * and outerrel will need to use this hook.
+	 *
+	 * A very simple way for an extension to use this hook is to set
+	 * extra.pgs_mask = 0, if it simply doesn't want any of the paths
+	 * generated by this call to add_paths_to_joinrel() to be selected. An
+	 * extension could use this technique to constrain the join order, since
+	 * it could thereby arrange to reject all paths from join orders that it
+	 * does not like. An extension can also selectively clear bits from
+	 * extra.pgs_mask to rule out specific techniques for specific joins, or
+	 * even replace the mask entirely.
+	 *
+	 * NB: Below this point, this function should be careful to reference
+	 * extra.pgs_mask rather than rel->pgs_mask to avoid disregarding any
+	 * changes made by the hook we're about to call.
+	 */
+	if (join_path_setup_hook)
+		join_path_setup_hook(root, joinrel, outerrel, innerrel,
+							 jointype, &extra);
+
 	/*
 	 * Find potential mergejoin clauses.  We can skip this if we are not
 	 * interested in doing a mergejoin.  However, mergejoin may be our only
-	 * way of implementing a full outer join, so override enable_mergejoin if
-	 * it's a full join.
+	 * way of implementing a full outer join, so in that case we don't care
+	 * whether mergejoins are disabled.
 	 */
-	if (enable_mergejoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_MERGEJOIN_ANY) != 0 || jointype == JOIN_FULL)
 		extra.mergeclause_list = select_mergejoin_clauses(root,
 														  joinrel,
 														  outerrel,
@@ -321,10 +348,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 
 	/*
 	 * 4. Consider paths where both outer and inner relations must be hashed
-	 * before being joined.  As above, disregard enable_hashjoin for full
-	 * joins, because there may be no other alternative.
+	 * before being joined.  As above, when it's a full join, we must try this
+	 * even when the path type is disabled, because it may be our only option.
 	 */
-	if (enable_hashjoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_HASHJOIN) != 0 || jointype == JOIN_FULL)
 		hash_inner_and_outer(root, joinrel, outerrel, innerrel,
 							 jointype, &extra);
 
@@ -333,7 +360,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	 * to the same server and assigned to the same user to check access
 	 * permissions as, give the FDW a chance to push down joins.
 	 */
-	if (joinrel->fdwroutine &&
+	if ((extra.pgs_mask & PGS_FOREIGNJOIN) != 0 && joinrel->fdwroutine &&
 		joinrel->fdwroutine->GetForeignJoinPaths)
 		joinrel->fdwroutine->GetForeignJoinPaths(root, joinrel,
 												 outerrel, innerrel,
@@ -342,8 +369,13 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * 6. Finally, give extensions a chance to manipulate the path list.  They
 	 * could add new paths (such as CustomPaths) by calling add_path(), or
-	 * add_partial_path() if parallel aware.  They could also delete or modify
-	 * paths added by the core code.
+	 * add_partial_path() if parallel aware.
+	 *
+	 * In theory, extensions could also use this hook to delete or modify
+	 * paths added by the core code, but in practice this is difficult to make
+	 * work, since it's too late to get back any paths that have already been
+	 * discarded by add_path() or add_partial_path(). If you're trying to
+	 * suppress paths, consider using join_path_setup_hook instead.
 	 */
 	if (set_join_pathlist_hook)
 		set_join_pathlist_hook(root, joinrel, outerrel, innerrel,
@@ -690,7 +722,7 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel,
 	List	   *ph_lateral_vars;
 
 	/* Obviously not if it's disabled */
-	if (!enable_memoize)
+	if ((extra->pgs_mask & PGS_NESTLOOP_MEMOIZE) == 0)
 		return NULL;
 
 	/*
@@ -845,6 +877,7 @@ try_nestloop_path(PlannerInfo *root,
 				  Path *inner_path,
 				  List *pathkeys,
 				  JoinType jointype,
+				  uint64 nestloop_subtype,
 				  JoinPathExtraData *extra)
 {
 	Relids		required_outer;
@@ -927,6 +960,7 @@ try_nestloop_path(PlannerInfo *root,
 	 * methodology worthwhile.
 	 */
 	initial_cost_nestloop(root, &workspace, jointype,
+						  nestloop_subtype | PGS_CONSIDER_NONPARTIAL,
 						  outer_path, inner_path, extra);
 
 	if (add_path_precheck(joinrel, workspace.disabled_nodes,
@@ -964,6 +998,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 						  Path *inner_path,
 						  List *pathkeys,
 						  JoinType jointype,
+						  uint64 nestloop_subtype,
 						  JoinPathExtraData *extra)
 {
 	JoinCostWorkspace workspace;
@@ -1011,7 +1046,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 	 * Before creating a path, get a quick lower bound on what it is likely to
 	 * cost.  Bail out right away if it looks terrible.
 	 */
-	initial_cost_nestloop(root, &workspace, jointype,
+	initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype,
 						  outer_path, inner_path, extra);
 	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
 								   workspace.total_cost, pathkeys))
@@ -1859,14 +1894,14 @@ match_unsorted_outer(PlannerInfo *root,
 	if (nestjoinOK)
 	{
 		/*
-		 * Consider materializing the cheapest inner path, unless
-		 * enable_material is off or the path in question materializes its
-		 * output anyway.
+		 * Consider materializing the cheapest inner path, unless that is
+		 * disabled or the path in question materializes its output anyway.
 		 */
-		if (enable_material && inner_cheapest_total != NULL &&
+		if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+			inner_cheapest_total != NULL &&
 			!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 			matpath = (Path *)
-				create_material_path(innerrel, inner_cheapest_total);
+				create_material_path(innerrel, inner_cheapest_total, true);
 	}
 
 	foreach(lc1, outerrel->pathlist)
@@ -1909,6 +1944,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  innerpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_PLAIN,
 								  extra);
 
 				/*
@@ -1925,6 +1961,7 @@ match_unsorted_outer(PlannerInfo *root,
 									  mpath,
 									  merge_pathkeys,
 									  jointype,
+									  PGS_NESTLOOP_MEMOIZE,
 									  extra);
 			}
 
@@ -1936,6 +1973,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  matpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_MATERIALIZE,
 								  extra);
 		}
 
@@ -2052,16 +2090,17 @@ consider_parallel_nestloop(PlannerInfo *root,
 
 	/*
 	 * Consider materializing the cheapest inner path, unless: 1)
-	 * enable_material is off, 2) the cheapest inner path is not
+	 * materialization is disabled here, 2) the cheapest inner path is not
 	 * parallel-safe, 3) the cheapest inner path is parameterized by the outer
 	 * rel, or 4) the cheapest inner path materializes its output anyway.
 	 */
-	if (enable_material && inner_cheapest_total->parallel_safe &&
+	if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+		inner_cheapest_total->parallel_safe &&
 		!PATH_PARAM_BY_REL(inner_cheapest_total, outerrel) &&
 		!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 	{
 		matpath = (Path *)
-			create_material_path(innerrel, inner_cheapest_total);
+			create_material_path(innerrel, inner_cheapest_total, true);
 		Assert(matpath->parallel_safe);
 	}
 
@@ -2091,7 +2130,8 @@ consider_parallel_nestloop(PlannerInfo *root,
 				continue;
 
 			try_partial_nestloop_path(root, joinrel, outerpath, innerpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_PLAIN, extra);
 
 			/*
 			 * Try generating a memoize path and see if that makes the nested
@@ -2102,13 +2142,15 @@ consider_parallel_nestloop(PlannerInfo *root,
 									 extra);
 			if (mpath != NULL)
 				try_partial_nestloop_path(root, joinrel, outerpath, mpath,
-										  pathkeys, jointype, extra);
+										  pathkeys, jointype,
+										  PGS_NESTLOOP_MEMOIZE, extra);
 		}
 
 		/* Also consider materialized form of the cheapest inner path */
 		if (matpath != NULL)
 			try_partial_nestloop_path(root, joinrel, outerpath, matpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_MATERIALIZE, extra);
 	}
 }
 
diff --git a/src/backend/optimizer/path/tidpath.c b/src/backend/optimizer/path/tidpath.c
index 2bfb338b81c..639a0d3cadb 100644
--- a/src/backend/optimizer/path/tidpath.c
+++ b/src/backend/optimizer/path/tidpath.c
@@ -500,18 +500,19 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	List	   *tidquals;
 	List	   *tidrangequals;
 	bool		isCurrentOf;
+	bool		enabled = (rel->pgs_mask & PGS_TIDSCAN) != 0;
 
 	/*
 	 * If any suitable quals exist in the rel's baserestrict list, generate a
 	 * plain (unparameterized) TidPath with them.
 	 *
-	 * We skip this when enable_tidscan = false, except when the qual is
+	 * We skip this when TID scans are disabled, except when the qual is
 	 * CurrentOfExpr. In that case, a TID scan is the only correct path.
 	 */
 	tidquals = TidQualFromRestrictInfoList(root, rel->baserestrictinfo, rel,
 										   &isCurrentOf);
 
-	if (tidquals != NIL && (enable_tidscan || isCurrentOf))
+	if (tidquals != NIL && (enabled || isCurrentOf))
 	{
 		/*
 		 * This path uses no join clauses, but it could still have required
@@ -533,7 +534,7 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	}
 
 	/* Skip the rest if TID scans are disabled. */
-	if (!enable_tidscan)
+	if (!enabled)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 88b4c5901b0..0a2e688b231 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -6503,7 +6503,7 @@ Plan *
 materialize_finished_plan(Plan *subplan)
 {
 	Plan	   *matplan;
-	Path		matpath;		/* dummy for result of cost_material */
+	Path		matpath;		/* dummy for cost_material */
 	Cost		initplan_cost;
 	bool		unsafe_initplans;
 
@@ -6525,7 +6525,9 @@ materialize_finished_plan(Plan *subplan)
 	subplan->total_cost -= initplan_cost;
 
 	/* Set cost data */
+	matpath.parent = NULL;
 	cost_material(&matpath,
+				  enable_material,
 				  subplan->disabled_nodes,
 				  subplan->startup_cost,
 				  subplan->total_cost,
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 8b1ab847f39..e2683b2481f 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -462,6 +462,53 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 		tuple_fraction = 0.0;
 	}
 
+	/*
+	 * Compute the initial path generation strategy mask.
+	 *
+	 * Some strategies, such as PGS_FOREIGNJOIN, have no corresponding enable_*
+	 * GUC, and so the corresponding bits are always set in the default
+	 * strategy mask.
+	 *
+	 * It may seem surprising that enable_indexscan sets both PGS_INDEXSCAN
+	 * and PGS_INDEXONLYSCAN. However, the historical behavior of this GUC
+	 * corresponds to this exactly: enable_indexscan=off disables both
+	 * index-scan and index-only scan paths, whereas enable_indexonlyscan=off
+	 * converts the index-only scan paths that we would have considered into
+	 * index scan paths.
+	 */
+	glob->default_pgs_mask = PGS_APPEND | PGS_MERGE_APPEND | PGS_FOREIGNJOIN |
+		PGS_GATHER | PGS_CONSIDER_NONPARTIAL;
+	if (enable_tidscan)
+		glob->default_pgs_mask |= PGS_TIDSCAN;
+	if (enable_seqscan)
+		glob->default_pgs_mask |= PGS_SEQSCAN;
+	if (enable_indexscan)
+		glob->default_pgs_mask |= PGS_INDEXSCAN | PGS_INDEXONLYSCAN;
+	if (enable_indexonlyscan)
+		glob->default_pgs_mask |= PGS_CONSIDER_INDEXONLY;
+	if (enable_bitmapscan)
+		glob->default_pgs_mask |= PGS_BITMAPSCAN;
+	if (enable_mergejoin)
+	{
+		glob->default_pgs_mask |= PGS_MERGEJOIN_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
+	if (enable_nestloop)
+	{
+		glob->default_pgs_mask |= PGS_NESTLOOP_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MATERIALIZE;
+		if (enable_memoize)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MEMOIZE;
+	}
+	if (enable_hashjoin)
+		glob->default_pgs_mask |= PGS_HASHJOIN;
+	if (enable_gathermerge)
+		glob->default_pgs_mask |= PGS_GATHER_MERGE;
+	if (enable_partitionwise_join)
+		glob->default_pgs_mask |= PGS_CONSIDER_PARTITIONWISE;
+
 	/* Allow plugins to take control after we've initialized "glob" */
 	if (planner_setup_hook)
 		(*planner_setup_hook) (glob, parse, query_string, &tuple_fraction, es);
@@ -3954,6 +4001,9 @@ make_grouping_rel(PlannerInfo *root, RelOptInfo *input_rel,
 		is_parallel_safe(root, (Node *) havingQual))
 		grouped_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed */
+	grouped_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the grouped rel.
 	 */
@@ -5348,6 +5398,9 @@ create_ordered_paths(PlannerInfo *root,
 	if (input_rel->consider_parallel && target_parallel_safe)
 		ordered_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed. */
+	ordered_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the ordered_rel.
 	 */
@@ -7428,6 +7481,7 @@ create_partial_grouping_paths(PlannerInfo *root,
 											grouped_rel->relids);
 	partially_grouped_rel->consider_parallel =
 		grouped_rel->consider_parallel;
+	partially_grouped_rel->pgs_mask = grouped_rel->pgs_mask;
 	partially_grouped_rel->reloptkind = grouped_rel->reloptkind;
 	partially_grouped_rel->serverid = grouped_rel->serverid;
 	partially_grouped_rel->userid = grouped_rel->userid;
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index c0a9811b130..eb57f0538ba 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1658,7 +1658,7 @@ create_group_result_path(PlannerInfo *root, RelOptInfo *rel,
  *	  pathnode.
  */
 MaterialPath *
-create_material_path(RelOptInfo *rel, Path *subpath)
+create_material_path(RelOptInfo *rel, Path *subpath, bool enabled)
 {
 	MaterialPath *pathnode = makeNode(MaterialPath);
 
@@ -1677,6 +1677,7 @@ create_material_path(RelOptInfo *rel, Path *subpath)
 	pathnode->subpath = subpath;
 
 	cost_material(&pathnode->path,
+				  enabled,
 				  subpath->disabled_nodes,
 				  subpath->startup_cost,
 				  subpath->total_cost,
@@ -1729,8 +1730,15 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath,
 	pathnode->est_unique_keys = 0.0;
 	pathnode->est_hit_ratio = 0.0;
 
-	/* we should not generate this path type when enable_memoize=false */
-	Assert(enable_memoize);
+	/*
+	 * We should not be asked to generate this path type when memoization is
+	 * disabled, so set our count of disabled nodes equal to the subpath's
+	 * count.
+	 *
+	 * It would be nice to also Assert that memoization is enabled, but the
+	 * value of enable_memoize is not controlling: what we would need to check
+	 * is that the JoinPathExtraData's pgs_mask included PGS_NESTLOOP_MEMOIZE.
+	 */
 	pathnode->path.disabled_nodes = subpath->disabled_nodes;
 
 	/*
@@ -3964,13 +3972,16 @@ reparameterize_path(PlannerInfo *root, Path *path,
 			{
 				MaterialPath *mpath = (MaterialPath *) path;
 				Path	   *spath = mpath->subpath;
+				bool		enabled;
 
 				spath = reparameterize_path(root, spath,
 											required_outer,
 											loop_count);
+				enabled =
+					(mpath->path.disabled_nodes <= spath->disabled_nodes);
 				if (spath == NULL)
 					return NULL;
-				return (Path *) create_material_path(rel, spath);
+				return (Path *) create_material_path(rel, spath, enabled);
 			}
 		case T_Memoize:
 			{
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index d950bd93002..ffd7bb3b221 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -557,6 +557,9 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 	 * Allow a plugin to editorialize on the info we obtained from the
 	 * catalogs.  Actions might include altering the assumed relation size,
 	 * removing an index, or adding a hypothetical index to the indexlist.
+	 *
+	 * An extension can also modify rel->pgs_mask here to control path
+	 * generation.
 	 */
 	if (get_relation_info_hook)
 		(*get_relation_info_hook) (root, relationObjectId, inhparent, rel);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 1158bc194c3..034d0c9c87a 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -47,6 +47,9 @@ typedef struct JoinHashEntry
 	RelOptInfo *join_rel;
 } JoinHashEntry;
 
+/* Hook for plugins to get control during joinrel setup */
+joinrel_setup_hook_type joinrel_setup_hook = NULL;
+
 static void build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 								RelOptInfo *input_rel,
 								SpecialJoinInfo *sjinfo,
@@ -225,6 +228,7 @@ build_simple_rel(PlannerInfo *root, int relid, RelOptInfo *parent)
 	rel->consider_startup = (root->tuple_fraction > 0);
 	rel->consider_param_startup = false;	/* might get changed later */
 	rel->consider_parallel = false; /* might get changed later */
+	rel->pgs_mask = root->glob->default_pgs_mask;
 	rel->reltarget = create_empty_pathtarget();
 	rel->pathlist = NIL;
 	rel->ppilist = NIL;
@@ -822,6 +826,7 @@ build_join_rel(PlannerInfo *root,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -934,10 +939,6 @@ build_join_rel(PlannerInfo *root,
 	 */
 	joinrel->has_eclass_joins = has_relevant_eclass_joinclause(root, joinrel);
 
-	/* Store the partition information. */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/*
 	 * Set estimates of the joinrel's size.
 	 */
@@ -963,6 +964,18 @@ build_join_rel(PlannerInfo *root,
 		is_parallel_safe(root, (Node *) joinrel->reltarget->exprs))
 		joinrel->consider_parallel = true;
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Store the partition information. */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* Add the joinrel to the PlannerInfo. */
 	add_join_rel(root, joinrel);
 
@@ -1019,6 +1032,7 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -1102,10 +1116,6 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	 */
 	joinrel->has_eclass_joins = parent_joinrel->has_eclass_joins;
 
-	/* Is the join between partitions itself partitioned? */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/* Child joinrel is parallel safe if parent is parallel safe. */
 	joinrel->consider_parallel = parent_joinrel->consider_parallel;
 
@@ -1113,6 +1123,20 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	set_joinrel_size_estimates(root, joinrel, outer_rel, inner_rel,
 							   sjinfo, restrictlist);
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel,
+	 * although the latter would be better done in the parent joinrel rather
+	 * than here.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Is the join between partitions itself partitioned? */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* We build the join only once. */
 	Assert(!find_join_rel(root, joinrel->relids));
 
@@ -1602,6 +1626,7 @@ fetch_upper_rel(PlannerInfo *root, UpperRelationKind kind, Relids relids)
 	upperrel = makeNode(RelOptInfo);
 	upperrel->reloptkind = RELOPT_UPPER_REL;
 	upperrel->relids = bms_copy(relids);
+	upperrel->pgs_mask = root->glob->default_pgs_mask;
 
 	/* cheap startup cost is interesting iff not all tuples to be retrieved */
 	upperrel->consider_startup = (root->tuple_fraction > 0);
@@ -2118,7 +2143,7 @@ build_joinrel_partition_info(PlannerInfo *root,
 	PartitionScheme part_scheme;
 
 	/* Nothing to do if partitionwise join technique is disabled. */
-	if (!enable_partitionwise_join)
+	if ((joinrel->pgs_mask & PGS_CONSIDER_PARTITIONWISE) == 0)
 	{
 		Assert(!IS_PARTITIONED_REL(joinrel));
 		return;
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 75a70489e5a..4746d3c43c4 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -22,6 +22,79 @@
 #include "nodes/parsenodes.h"
 #include "storage/block.h"
 
+/*
+ * Path generation strategies.
+ *
+ * These constants are used to specify the set of strategies that the planner
+ * should use, either for the query as a whole or for a specific baserel or
+ * joinrel. The various planner-related enable_* GUCs are used to set the
+ * PlannerGlobal's default_pgs_mask, and that in turn is used to set each
+ * RelOptInfo's pgs_mask. In both cases, extensions can use hooks to modify the
+ * default value.  Not every strategy listed here has a corresponding enable_*
+ * GUC; those that don't are always allowed unless disabled by an extension.
+ * Not all strategies are relevant for every RelOptInfo; e.g. PGS_SEQSCAN
+ * doesn't affect joinrels one way or the other.
+ *
+ * In most cases, disabling a path generation strategy merely means that any
+ * paths generated using that strategy are marked as disabled, but in some
+ * cases, path generation is skipped altogether. The latter strategy is only
+ * permissible when it can't result in planner failure -- for instance, we
+ * couldn't do this for sequential scans on a plain rel, because there might
+ * not be any other possible path. Nevertheless, the behaviors in each
+ * individual case are to some extent the result of historical accident,
+ * chosen to match the preexisting behaviors of the enable_* GUCs.
+ *
+ * In a few cases, we have more than one bit for the same strategy, controlling
+ * different aspects of the planner behavior. When PGS_CONSIDER_INDEXONLY is
+ * unset, we don't even consider index-only scans, and any such scans that
+ * would have been generated become index scans instead. On the other hand,
+ * unsetting PGS_INDEXSCAN or PGS_INDEXONLYSCAN causes generated paths of the
+ * corresponding types to be marked as disabled. Similarly, unsetting
+ * PGS_CONSIDER_PARTITIONWISE prevents any sort of thinking about partitionwise
+ * joins for the current rel, which incidentally will preclude higher-level
+ * joinrels from building parititonwise paths using paths taken from the
+ * current rel's children. On the other hand, unsetting PGS_APPEND or
+ * PGS_MERGE_APPEND will only arrange to disable paths of the corresponding
+ * types if they are generated at the level of the current rel.
+ *
+ * Finally, unsetting PGS_CONSIDER_NONPARTIAL disables all non-partial paths
+ * except those that use Gather or Gather Merge. In most other cases, a
+ * plugin can nudge the planner toward a particular strategy by disabling
+ * all of the others, but that doesn't work here: unsetting PGS_SEQSCAN,
+ * for instance, would disable both partial and non-partial sequential scans.
+ */
+#define PGS_SEQSCAN					0x00000001
+#define PGS_INDEXSCAN				0x00000002
+#define PGS_INDEXONLYSCAN			0x00000004
+#define PGS_BITMAPSCAN				0x00000008
+#define PGS_TIDSCAN					0x00000010
+#define PGS_FOREIGNJOIN				0x00000020
+#define PGS_MERGEJOIN_PLAIN			0x00000040
+#define PGS_MERGEJOIN_MATERIALIZE	0x00000080
+#define PGS_NESTLOOP_PLAIN			0x00000100
+#define PGS_NESTLOOP_MATERIALIZE	0x00000200
+#define PGS_NESTLOOP_MEMOIZE		0x00000400
+#define PGS_HASHJOIN				0x00000800
+#define PGS_APPEND					0x00001000
+#define PGS_MERGE_APPEND			0x00002000
+#define PGS_GATHER					0x00004000
+#define PGS_GATHER_MERGE			0x00008000
+#define PGS_CONSIDER_INDEXONLY		0x00010000
+#define PGS_CONSIDER_PARTITIONWISE	0x00020000
+#define PGS_CONSIDER_NONPARTIAL		0x00040000
+
+/*
+ * Convenience macros for useful combination of the bits defined above.
+ */
+#define PGS_SCAN_ANY		\
+	(PGS_SEQSCAN | PGS_INDEXSCAN | PGS_INDEXONLYSCAN | PGS_BITMAPSCAN | \
+	 PGS_TIDSCAN)
+#define PGS_MERGEJOIN_ANY	\
+	(PGS_MERGEJOIN_PLAIN | PGS_MERGEJOIN_MATERIALIZE)
+#define PGS_NESTLOOP_ANY	\
+	(PGS_NESTLOOP_PLAIN | PGS_NESTLOOP_MATERIALIZE | PGS_NESTLOOP_MEMOIZE)
+#define PGS_JOIN_ANY		\
+	(PGS_FOREIGNJOIN | PGS_MERGEJOIN_ANY | PGS_NESTLOOP_ANY | PGS_HASHJOIN)
 
 /*
  * Relids
@@ -186,6 +259,9 @@ typedef struct PlannerGlobal
 	/* worst PROPARALLEL hazard level */
 	char		maxParallelHazard;
 
+	/* mask of allowed path generation strategies */
+	uint64		default_pgs_mask;
+
 	/* partition descriptors */
 	PartitionDirectory partition_directory pg_node_attr(read_write_ignore);
 
@@ -939,7 +1015,7 @@ typedef struct RelOptInfo
 	Cardinality rows;
 
 	/*
-	 * per-relation planner control flags
+	 * per-relation planner control
 	 */
 	/* keep cheap-startup-cost paths? */
 	bool		consider_startup;
@@ -947,6 +1023,8 @@ typedef struct RelOptInfo
 	bool		consider_param_startup;
 	/* consider parallel paths? */
 	bool		consider_parallel;
+	/* path generation strategy mask */
+	uint64		pgs_mask;
 
 	/*
 	 * default result targetlist for Paths scanning this relation; list of
@@ -3505,6 +3583,7 @@ typedef struct SemiAntiJoinFactors
  * sjinfo is extra info about special joins for selectivity estimation
  * semifactors is as shown above (only valid for SEMI/ANTI/inner_unique joins)
  * param_source_rels are OK targets for parameterization of result paths
+ * pgs_mask is a bitmask of PGS_* constants to limit the join strategy
  */
 typedef struct JoinPathExtraData
 {
@@ -3514,6 +3593,7 @@ typedef struct JoinPathExtraData
 	SpecialJoinInfo *sjinfo;
 	SemiAntiJoinFactors semifactors;
 	Relids		param_source_rels;
+	uint64		pgs_mask;
 } JoinPathExtraData;
 
 /*
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index b523bcda8f3..2d80462bece 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -125,7 +125,7 @@ extern void cost_merge_append(Path *path, PlannerInfo *root,
 							  Cost input_startup_cost, Cost input_total_cost,
 							  double tuples);
 extern void cost_material(Path *path,
-						  int input_disabled_nodes,
+						  bool enabled, int input_disabled_nodes,
 						  Cost input_startup_cost, Cost input_total_cost,
 						  double tuples, int width);
 extern void cost_agg(Path *path, PlannerInfo *root,
@@ -148,7 +148,7 @@ extern void cost_group(Path *path, PlannerInfo *root,
 					   double input_tuples);
 extern void initial_cost_nestloop(PlannerInfo *root,
 								  JoinCostWorkspace *workspace,
-								  JoinType jointype,
+								  JoinType jointype, uint64 enable_mask,
 								  Path *outer_path, Path *inner_path,
 								  JoinPathExtraData *extra);
 extern void final_cost_nestloop(PlannerInfo *root, NestPath *path,
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 4437248cb67..274cd41bab1 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -17,6 +17,14 @@
 #include "nodes/bitmapset.h"
 #include "nodes/pathnodes.h"
 
+/* Hook for plugins to get control during joinrel setup */
+typedef void (*joinrel_setup_hook_type) (PlannerInfo *root,
+										 RelOptInfo *joinrel,
+										 RelOptInfo *outer_rel,
+										 RelOptInfo *inner_rel,
+										 SpecialJoinInfo *sjinfo,
+										 List *restrictlist);
+extern PGDLLIMPORT joinrel_setup_hook_type joinrel_setup_hook;
 
 /*
  * prototypes for pathnode.c
@@ -84,7 +92,8 @@ extern GroupResultPath *create_group_result_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 PathTarget *target,
 												 List *havingqual);
-extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath);
+extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath,
+										  bool enabled);
 extern MemoizePath *create_memoize_path(PlannerInfo *root,
 										RelOptInfo *rel,
 										Path *subpath,
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index f6a62df0b43..61c1607f872 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -28,7 +28,14 @@ extern PGDLLIMPORT int min_parallel_table_scan_size;
 extern PGDLLIMPORT int min_parallel_index_scan_size;
 extern PGDLLIMPORT bool enable_group_by_reordering;
 
-/* Hook for plugins to get control in set_rel_pathlist() */
+/* Hooks for plugins to get control in set_rel_pathlist() */
+typedef void (*join_path_setup_hook_type) (PlannerInfo *root,
+										   RelOptInfo *joinrel,
+										   RelOptInfo *outerrel,
+										   RelOptInfo *innerrel,
+										   JoinType jointype,
+										   JoinPathExtraData *extra);
+extern PGDLLIMPORT join_path_setup_hook_type join_path_setup_hook;
 typedef void (*set_rel_pathlist_hook_type) (PlannerInfo *root,
 											RelOptInfo *rel,
 											Index rti,
-- 
2.51.0



  [application/octet-stream] v4-0006-WIP-Add-pg_plan_advice-contrib-module.patch (375.1K, 7-v4-0006-WIP-Add-pg_plan_advice-contrib-module.patch)
  download | inline diff:
From fac58fe41828805976d289d206437168a4aa7106 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 4 Nov 2025 14:45:31 -0500
Subject: [PATCH v4 6/6] WIP: Add pg_plan_advice contrib module.

Provide a facility that (1) can be used to stabilize certain plan choices
so that the planner cannot reverse course without authorization and
(2) can be used by knowledgeable users to insist on plan choices contrary
to what the planner believes best. In both cases, terrible outcomes are
possible: users should think twice and perhaps three times before
constraining the planner's ability to do as it thinks best; nevertheless,
there are problems that are much more easily solved with these facilities
than without them.

We take the approach of analyzing a finished plan to produce textual
output, which we call "plan advice", that describes key decisions made
during plan; if that plan advice is provided during future planning
cycles, it will force those key decisions to be made in the same way.
Not all planner decisions can be controlled using advice; for example,
decisions about how to perform aggregation are currently out of scope,
as is choice of sort order. Plan advice can also be edited by the user,
or even written from scratch in simple cases, making it possible to
generate outcomes that the planner would not have produced. Partial
advice can be provided to control some planner outcomes but not others.

Currently, plan advice is focused only on specific outcomes, such as
the choice to use a sequential scan for a particular relation, and not
on estimates that might contribute to those outcomes, such as a
possibly-incorrect selectivity estimate. While it would be useful to
users to be able to provide plan advice that affects selectivity
estimates or other aspects of costing, that is out of scope for this
commit.

For more details, see contrib/pg_plan_advice/README.

NOTE: This code is just a proof of concept. A bunch of things don't
work and a lot of the code needs cleanup. It has no SGML documentation
and not enough test cases, and some of the existing test cases don't
do as we would hope. Known problems are called out by XXX.
---
 contrib/Makefile                              |    1 +
 contrib/meson.build                           |    1 +
 contrib/pg_plan_advice/.gitignore             |    3 +
 contrib/pg_plan_advice/Makefile               |   50 +
 contrib/pg_plan_advice/README                 |  275 +++
 contrib/pg_plan_advice/expected/gather.out    |  320 ++++
 .../pg_plan_advice/expected/join_order.out    |  292 +++
 .../pg_plan_advice/expected/join_strategy.out |  297 +++
 .../expected/local_collector.out              |   65 +
 .../pg_plan_advice/expected/partitionwise.out |  243 +++
 contrib/pg_plan_advice/expected/scan.out      |  757 ++++++++
 contrib/pg_plan_advice/expected/syntax.out    |   59 +
 contrib/pg_plan_advice/meson.build            |   70 +
 .../pg_plan_advice/pg_plan_advice--1.0.sql    |   42 +
 contrib/pg_plan_advice/pg_plan_advice.c       |  454 +++++
 contrib/pg_plan_advice/pg_plan_advice.control |    5 +
 contrib/pg_plan_advice/pg_plan_advice.h       |   37 +
 contrib/pg_plan_advice/pgpa_ast.c             |  392 ++++
 contrib/pg_plan_advice/pgpa_ast.h             |  204 ++
 contrib/pg_plan_advice/pgpa_collector.c       |  637 ++++++
 contrib/pg_plan_advice/pgpa_collector.h       |   18 +
 contrib/pg_plan_advice/pgpa_identifier.c      |  476 +++++
 contrib/pg_plan_advice/pgpa_identifier.h      |   52 +
 contrib/pg_plan_advice/pgpa_join.c            |  615 ++++++
 contrib/pg_plan_advice/pgpa_join.h            |  105 +
 contrib/pg_plan_advice/pgpa_output.c          |  628 ++++++
 contrib/pg_plan_advice/pgpa_output.h          |   22 +
 contrib/pg_plan_advice/pgpa_parser.y          |  337 ++++
 contrib/pg_plan_advice/pgpa_planner.c         | 1706 +++++++++++++++++
 contrib/pg_plan_advice/pgpa_planner.h         |   17 +
 contrib/pg_plan_advice/pgpa_scan.c            |  258 +++
 contrib/pg_plan_advice/pgpa_scan.h            |   86 +
 contrib/pg_plan_advice/pgpa_scanner.l         |  299 +++
 contrib/pg_plan_advice/pgpa_trove.c           |  490 +++++
 contrib/pg_plan_advice/pgpa_trove.h           |  113 ++
 contrib/pg_plan_advice/pgpa_walker.c          |  890 +++++++++
 contrib/pg_plan_advice/pgpa_walker.h          |  122 ++
 contrib/pg_plan_advice/sql/gather.sql         |   76 +
 contrib/pg_plan_advice/sql/join_order.sql     |   96 +
 contrib/pg_plan_advice/sql/join_strategy.sql  |   76 +
 .../pg_plan_advice/sql/local_collector.sql    |   41 +
 contrib/pg_plan_advice/sql/partitionwise.sql  |   78 +
 contrib/pg_plan_advice/sql/scan.sql           |  195 ++
 contrib/pg_plan_advice/sql/syntax.sql         |   42 +
 contrib/pg_plan_advice/t/001_regress.pl       |  147 ++
 src/tools/pgindent/typedefs.list              |   37 +
 46 files changed, 11226 insertions(+)
 create mode 100644 contrib/pg_plan_advice/.gitignore
 create mode 100644 contrib/pg_plan_advice/Makefile
 create mode 100644 contrib/pg_plan_advice/README
 create mode 100644 contrib/pg_plan_advice/expected/gather.out
 create mode 100644 contrib/pg_plan_advice/expected/join_order.out
 create mode 100644 contrib/pg_plan_advice/expected/join_strategy.out
 create mode 100644 contrib/pg_plan_advice/expected/local_collector.out
 create mode 100644 contrib/pg_plan_advice/expected/partitionwise.out
 create mode 100644 contrib/pg_plan_advice/expected/scan.out
 create mode 100644 contrib/pg_plan_advice/expected/syntax.out
 create mode 100644 contrib/pg_plan_advice/meson.build
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice--1.0.sql
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.c
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.control
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.h
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.c
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.h
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.c
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.h
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.c
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.h
 create mode 100644 contrib/pg_plan_advice/pgpa_join.c
 create mode 100644 contrib/pg_plan_advice/pgpa_join.h
 create mode 100644 contrib/pg_plan_advice/pgpa_output.c
 create mode 100644 contrib/pg_plan_advice/pgpa_output.h
 create mode 100644 contrib/pg_plan_advice/pgpa_parser.y
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.c
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.c
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scanner.l
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.c
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.h
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.c
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.h
 create mode 100644 contrib/pg_plan_advice/sql/gather.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_order.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_strategy.sql
 create mode 100644 contrib/pg_plan_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_plan_advice/sql/partitionwise.sql
 create mode 100644 contrib/pg_plan_advice/sql/scan.sql
 create mode 100644 contrib/pg_plan_advice/sql/syntax.sql
 create mode 100644 contrib/pg_plan_advice/t/001_regress.pl

diff --git a/contrib/Makefile b/contrib/Makefile
index 2f0a88d3f77..dd04c20acd2 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -34,6 +34,7 @@ SUBDIRS = \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
+		pg_plan_advice \
 		pg_prewarm	\
 		pg_stat_statements \
 		pg_surgery	\
diff --git a/contrib/meson.build b/contrib/meson.build
index ed30ee7d639..cb718dbdac0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -48,6 +48,7 @@ subdir('pgcrypto')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
+subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
 subdir('pg_stat_statements')
diff --git a/contrib/pg_plan_advice/.gitignore b/contrib/pg_plan_advice/.gitignore
new file mode 100644
index 00000000000..19a14253019
--- /dev/null
+++ b/contrib/pg_plan_advice/.gitignore
@@ -0,0 +1,3 @@
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
new file mode 100644
index 00000000000..1d4c559aed8
--- /dev/null
+++ b/contrib/pg_plan_advice/Makefile
@@ -0,0 +1,50 @@
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_plan_advice.o \
+	pgpa_ast.o \
+	pgpa_collector.o \
+	pgpa_identifier.o \
+	pgpa_join.o \
+	pgpa_output.o \
+	pgpa_parser.o \
+	pgpa_planner.o \
+	pgpa_scan.o \
+	pgpa_scanner.o \
+	pgpa_trove.o \
+	pgpa_walker.o
+
+EXTENSION = pg_plan_advice
+DATA = pg_plan_advice--1.0.sql
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise scan
+TAP_TESTS = 1
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+# required for 001_regress.pl
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+	touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
new file mode 100644
index 00000000000..4590cd03ce5
--- /dev/null
+++ b/contrib/pg_plan_advice/README
@@ -0,0 +1,275 @@
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planing
+cycle, each piece of advice should be guaranteed to apply to the exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should succesfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it is appears to be good advice or bad advice.  If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+   e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously.  Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables.  When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by curly brances, e.g. SOME_SORT_OF_SCAN(foo (bar baz)) would mean that the
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+Bitmap heap scans allow for a more complicated index specification. For
+example, BITMAP_HEAP_SCAN(foo &&(foo_a_idx ||(foo_b_idx foo_c_idx))) says
+that foo should be scanned using a BitmapHeapScan over a BitmapAnd between
+foo_a_idx and the result of a BitmapOr between foo_b_idx and foo_c_idx.
+
+XXX: Currently, BITMAP_HEAP_SCAN does not enforce the index specification,
+because the available hooks are insufficient to do so. It's possible that
+this should be changed to exclude the index specification altogether and
+simply insist that some sort of bitmap heap scan is used; alternatively,
+we need better hooks.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ORDER() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructured. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Strategy Advice
+====================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+strategy advice; no join strategy advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join strategy advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the planner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join strategy advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join strategy advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join strategy advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+XXX: Currently, SEMIJOIN_NON_UNIQUE() advice is emitted in some situations
+where the SEMIJOIN_UNIQUE() approach was determined to be non-viable; ideally,
+we should avoid that.
+
+XXX: Right semijoins haven't been properly thought through. The associated
+code probably just doesn't work.
+
+XXX: Semijoin uniqueness advice has no automated tests and need substantially
+more manual testing.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advise can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constraint the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the outer
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constraint won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
new file mode 100644
index 00000000000..d0224a2aee7
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -0,0 +1,320 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Sort Key: f.dim_id
+   ->  Gather
+         Workers Planned: 1
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(f d)
+(20 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(d)
+   NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(d)
+   NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   NO_GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Disabled: true
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(14 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
new file mode 100644
index 00000000000..e87652370c3
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -0,0 +1,292 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   HASH_JOIN(d1 d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (d1.id = f.dim1_id)
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Hash
+               ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 f d2)
+   HASH_JOIN(f d2)
+   SEQ_SCAN(d1 f d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN(d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Disabled: true
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_PLAIN(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
new file mode 100644
index 00000000000..71ee26a337a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -0,0 +1,297 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Disabled: true
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Materialize
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MATERIALIZE(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Memoize
+         Cache Key: f.dim_id
+         Cache Mode: logical
+         ->  Index Scan using join_dim_pkey on join_dim d
+               Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MEMOIZE(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN              
+-------------------------------------
+ Hash Join
+   Hash Cond: (d.id = f.dim_id)
+   ->  Seq Scan on join_dim d
+   ->  Hash
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   HASH_JOIN(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Materialize
+         ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_MATERIALIZE(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_dim d
+   ->  Materialize
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MATERIALIZE(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Memoize
+         Cache Key: d.id
+         Cache Mode: logical
+         ->  Index Scan using join_fact_dim_id on join_fact f
+               Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MEMOIZE(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+         Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_PLAIN(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(13 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/local_collector.out b/contrib/pg_plan_advice/expected/local_collector.out
new file mode 100644
index 00000000000..8024a063a04
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/local_collector.out
@@ -0,0 +1,65 @@
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
new file mode 100644
index 00000000000..df0f05531d5
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -0,0 +1,243 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt2.id)
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1b pt1_2
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1c pt1_3
+               Filter: (val1 = 1)
+   ->  Hash
+         ->  Hash Join
+               Hash Cond: (pt2.id = pt3.id)
+               ->  Append
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+               ->  Hash
+                     ->  Append
+                           ->  Seq Scan on pt3a pt3_1
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3b pt3_2
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3c pt3_3
+                                 Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE(pt1) /* matched */
+   PARTITIONWISE(pt2) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 (pt2 pt3))
+   HASH_JOIN(pt3 pt3)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE(pt1 pt2 pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(40 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt3.id)
+   ->  Append
+         ->  Hash Join
+               Hash Cond: (pt1_1.id = pt2_1.id)
+               ->  Seq Scan on pt1a pt1_1
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_2.id = pt2_2.id)
+               ->  Seq Scan on pt1b pt1_2
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_3.id = pt2_3.id)
+               ->  Seq Scan on pt1c pt1_3
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+   ->  Hash
+         ->  Append
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3b pt3_2
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3c pt3_3
+                     Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+   JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+   JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+   JOIN_ORDER({pt1 pt2} pt3)
+   HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+   SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+    pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE((pt1 pt2) pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+         ->  Seq Scan on pt1b pt1_2
+         ->  Seq Scan on pt1c pt1_3
+   ->  Append
+         ->  Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+               Index Cond: (id = pt1.id)
+         ->  Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+               Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 ptmismatch)
+   NESTED_LOOP_PLAIN(ptmismatch)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+    ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+   PARTITIONWISE(pt1 ptmismatch)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+    ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
new file mode 100644
index 00000000000..61f361fcf9c
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -0,0 +1,757 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+       QUERY PLAN        
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                     QUERY PLAN                     
+----------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+            QUERY PLAN             
+-----------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_b) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a > 0)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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 > 0;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a > 0)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (a > 0)
+   ->  Bitmap Index Scan on scan_table_pkey
+         Index Cond: (a > 0)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(9 rows)
+
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_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:
+   TID_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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table cilbup.scan_table_pkey) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, conflicting */
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched, conflicting */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Nested Loop Left Join
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s s#2)
+   INDEX_SCAN(s public.scan_table_pkey s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop Left Join
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s#2)
+   HASH_JOIN(s)
+   SEQ_SCAN(s)
+   INDEX_SCAN(s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s)
+   HASH_JOIN(s#2)
+   SEQ_SCAN(s#2)
+   INDEX_SCAN(s public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   HASH_JOIN(s s#2)
+   SEQ_SCAN(s s#2)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+COMMIT;
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(5 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(5 rows)
+
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+          QUERY PLAN           
+-------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@x)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                    QUERY PLAN                    
+--------------------------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/syntax.out b/contrib/pg_plan_advice/expected/syntax.out
new file mode 100644
index 00000000000..dddb12cae58
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/syntax.out
@@ -0,0 +1,59 @@
+LOAD 'pg_plan_advice';
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQUENTIAL_SCAN(x)"
+DETAIL:  Could not parse advice: syntax error at or near "SEQUENTIAL_SCAN"
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN"
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(""
+DETAIL:  Could not parse advice: unterminated quoted identifier at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(#"
+DETAIL:  Could not parse advice: syntax error at or near "#"
+SET pg_plan_advice.advice = '()';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "()"
+DETAIL:  Could not parse advice: syntax error at or near "("
+SET pg_plan_advice.advice = '123';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "123"
+DETAIL:  Could not parse advice: syntax error at or near "123"
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "JOIN_ORDER("fOO") /* oops"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*/* stuff */*/"
+DETAIL:  Could not parse advice: syntax error at or near "*"
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN(a)"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN((a))"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
new file mode 100644
index 00000000000..3452e5ad48e
--- /dev/null
+++ b/contrib/pg_plan_advice/meson.build
@@ -0,0 +1,70 @@
+# Copyright (c) 2022-2024, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+  'pg_plan_advice.c',
+  'pgpa_ast.c',
+  'pgpa_collector.c',
+  'pgpa_identifier.c',
+  'pgpa_join.c',
+  'pgpa_output.c',
+  'pgpa_planner.c',
+  'pgpa_scan.c',
+  'pgpa_trove.c',
+  'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+  input: 'pgpa_scanner.l',
+  output: 'pgpa_scanner.c',
+  command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+  input: 'pgpa_parser.y',
+  kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+  pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_plan_advice',
+    '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice = shared_module('pg_plan_advice',
+  pg_plan_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_data(
+  'pg_plan_advice--1.0.sql',
+  'pg_plan_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gather',
+      'join_order',
+      'join_strategy',
+      'local_collector',
+      'partitionwise',
+      'scan',
+      'syntax',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_regress.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice--1.0.sql b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
new file mode 100644
index 00000000000..29f4f224864
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
@@ -0,0 +1,42 @@
+/* contrib/pg_plan_advice/pg_plan_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_plan_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_plan_advice/pg_plan_advice.c b/contrib/pg_plan_advice/pg_plan_advice.c
new file mode 100644
index 00000000000..f32e8b7a0d3
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.c
@@ -0,0 +1,454 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ *	  main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static pgpa_shared_state *pgpa_state = NULL;
+static dsa_area *pgpa_dsa_area = NULL;
+
+/* GUC variables */
+char	   *pg_plan_advice_advice = NULL;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+int			pg_plan_advice_local_collection_limit = 0;
+int			pg_plan_advice_shared_collection_limit = 0;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int	es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+												  DefElem *opt,
+												  ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+												 IntoClause *into,
+												 ExplainState *es,
+												 const char *queryString,
+												 ParamListInfo params,
+												 QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+											 GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("pg_plan_advice.advice",
+							   "advice to apply during query planning",
+							   NULL,
+							   &pg_plan_advice_advice,
+							   NULL,
+							   PGC_USERSET,
+							   0,
+							   pg_plan_advice_advice_check_hook,
+							   NULL,
+							   NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+							 "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+							 NULL,
+							 &pg_plan_advice_always_explain_supplied_advice,
+							 true,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_plan_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_plan_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_plan_advice");
+
+	/* Get an ID that we can use to cache data in an ExplainState. */
+	es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+	/* Register the new EXPLAIN options implemented by this module. */
+	RegisterExtensionExplainOption("plan_advice",
+								   pg_plan_advice_explain_option_handler);
+
+	/* Install hooks */
+	pgpa_planner_install_hooks();
+	prev_explain_per_plan = explain_per_plan_hook;
+	explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgpa_init_shared_state(void *ptr)
+{
+	pgpa_shared_state *state = (pgpa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock, LWLockNewTrancheId("pg_plan_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_plan_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+	if (pgpa_memory_context == NULL)
+		pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_plan_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgpa_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ *
+ * Along the way, make sure the relevant LWLock tranches are registered.
+ */
+pgpa_shared_state *
+pg_plan_advice_attach(void)
+{
+	if (pgpa_state == NULL)
+	{
+		bool		found;
+
+		pgpa_state =
+			GetNamedDSMSegment("pg_plan_advice", sizeof(pgpa_shared_state),
+							   pgpa_init_shared_state, &found);
+	}
+
+	return pgpa_state;
+}
+
+/*
+ * Return a pointer to pg_plan_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_plan_advice_dsa_area(void)
+{
+	if (pgpa_dsa_area == NULL)
+	{
+		pgpa_shared_state *state = pg_plan_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgpa_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgpa_dsa_area);
+			state->area = dsa_get_handle(pgpa_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgpa_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgpa_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgpa_dsa_area;
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+									  ParseState *pstate)
+{
+	bool	   *plan_advice;
+
+	plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+	if (plan_advice == NULL)
+	{
+		plan_advice = palloc0_object(bool);
+		SetExplainExtensionState(es, es_extension_id, plan_advice);
+	}
+
+	*plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+									  char *value)
+{
+	char	   *s;
+
+	/* For non-text formats, it's best not to add any special handling. */
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		ExplainPropertyText(qlabel, value, es);
+		return;
+	}
+
+	/* In text format, if there is no data, display nothing. */
+	if (*qlabel == '\0')
+		return;
+
+	/*
+	 * It looks nicest to indent each line of the advice separately, beginning
+	 * on the line below the label.
+	 */
+	ExplainIndentText(es);
+	appendStringInfo(es->str, "%s:\n", qlabel);
+	es->indent++;
+	while ((s = strchr(value, '\n')) != NULL)
+	{
+		ExplainIndentText(es);
+		appendBinaryStringInfo(es->str, value, (s - value) + 1);
+		value = s + 1;
+	}
+
+	/* Don't interpret a terminal newline as a request for an empty line. */
+	if (*value != '\0')
+	{
+		ExplainIndentText(es);
+		appendStringInfo(es->str, "%s\n", value);
+	}
+
+	es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		appendStringInfo(&buf, "%s /* ", item->defname);
+		if ((flags & PGPA_TE_MATCH_FULL) != 0)
+		{
+			Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+			appendStringInfo(&buf, "matched");
+		}
+		else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+			appendStringInfo(&buf, "partially matched");
+		else
+			appendStringInfo(&buf, "not matched");
+		if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+			appendStringInfo(&buf, ", inapplicable");
+		if ((flags & PGPA_TE_CONFLICTING) != 0)
+			appendStringInfo(&buf, ", conflicting");
+		if ((flags & PGPA_TE_FAILED) != 0)
+			appendStringInfo(&buf, ", failed");
+		appendStringInfo(&buf, " */\n");
+	}
+
+	pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+										  buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+									 IntoClause *into,
+									 ExplainState *es,
+									 const char *queryString,
+									 ParamListInfo params,
+									 QueryEnvironment *queryEnv)
+{
+	bool	   *plan_advice = GetExplainExtensionState(es, es_extension_id);
+	DefElem    *pgpa_item;
+	List	   *pgpa_list;
+
+	if (prev_explain_per_plan)
+		prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+							  queryEnv);
+
+	/* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+	pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+										"pg_plan_advice");
+	pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+	/*
+	 * By default, if there is a record of attempting to apply advice during
+	 * query planning, we always output that information, but the user can set
+	 * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+	 * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+	 * was specified and not set to false.
+	 *
+	 * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+	 * statement -- the application of query advice may not have been
+	 * recorded, and therefore this won't be able to show anything.
+	 */
+	if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+							  (plan_advice != NULL && *plan_advice)))
+	{
+		DefElem    *feedback;
+
+		feedback = find_defelem_by_defname(pgpa_list, "feedback");
+		if (feedback != NULL)
+			pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+	}
+
+	/*
+	 * If the PLAN_ADVICE option was specified -- and not sent to FALSE --
+	 * show generated advice.
+	 */
+	if (plan_advice != NULL && *plan_advice)
+	{
+		DefElem    *advice_string_item;
+		char	   *advice_string;
+
+		advice_string_item =
+			find_defelem_by_defname(pgpa_list, "advice_string");
+		if (advice_string_item != NULL)
+		{
+			/* Advice has already been generated; we can reuse it. */
+			advice_string = strVal(advice_string_item->arg);
+		}
+		else
+		{
+			pgpa_plan_walker_context walker;
+			StringInfoData buf;
+			pgpa_identifier *rt_identifiers;
+
+			/* Advice not yet generated; do that now. */
+			pgpa_plan_walker(&walker, plannedstmt);
+			rt_identifiers =
+				pgpa_create_identifiers_for_planned_stmt(plannedstmt);
+			initStringInfo(&buf);
+			pgpa_output_advice(&buf, &walker, rt_identifiers);
+			advice_string = buf.data;
+		}
+
+		if (advice_string[0] != '\0')
+			pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+												  advice_string);
+	}
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+	MemoryContext oldcontext;
+	MemoryContext tmpcontext;
+	char	   *error;
+
+	if (*newval == NULL)
+		return true;
+
+	tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+									   "pg_plan_advice.advice",
+									   ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+	/*
+	 * It would be nice to save the parse tree that we construct here for
+	 * eventual use when planning with this advice, but *extra can only point
+	 * to a single guc_malloc'd chunk, and our parse tree involves an
+	 * arbitrary number of memory allocations.
+	 */
+	(void) pgpa_parse(*newval, &error);
+
+	if (error != NULL)
+	{
+		GUC_check_errdetail("Could not parse advice: %s", error);
+		return false;
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+	MemoryContextDelete(tmpcontext);
+
+	return true;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.control b/contrib/pg_plan_advice/pg_plan_advice.control
new file mode 100644
index 00000000000..aa6fdc9e7b2
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.control
@@ -0,0 +1,5 @@
+# pg_plan_advice extension
+comment = 'help the planner get the right plan'
+default_version = '1.0'
+module_pathname = '$libdir/pg_plan_advice'
+relocatable = true
diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
new file mode 100644
index 00000000000..86efb3b6113
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -0,0 +1,37 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ *	  main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "nodes/plannodes.h"
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgpa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgpa_shared_state;
+
+/* GUC variables */
+extern int	pg_plan_advice_local_collection_limit;
+extern int	pg_plan_advice_shared_collection_limit;
+extern char *pg_plan_advice_advice;
+
+/* Function prototypes */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern pgpa_shared_state *pg_plan_advice_attach(void);
+extern dsa_area *pg_plan_advice_dsa_area(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
new file mode 100644
index 00000000000..02ffbfa3760
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -0,0 +1,392 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ *	  additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+										  pgpa_advice_target *target,
+										  bool *rids_used);
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+	switch (advice_tag)
+	{
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_FOREIGN_JOIN:
+			return "FOREIGN_JOIN";
+		case PGPA_TAG_GATHER:
+			return "GATHER";
+		case PGPA_TAG_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPA_TAG_HASH_JOIN:
+			return "HASH_JOIN";
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_TAG_INDEX_SCAN:
+			return "INDEX_SCAN";
+		case PGPA_TAG_JOIN_ORDER:
+			return "JOIN_ORDER";
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case PGPA_TAG_NO_GATHER:
+			return "NO_GATHER";
+		case PGPA_TAG_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+		case PGPA_TAG_SEQ_SCAN:
+			return "SEQ_SCAN";
+		case PGPA_TAG_TID_SCAN:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and reurn an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+	*fail = false;
+
+	switch (tag[0])
+	{
+		case 'b':
+			if (strcmp(tag, "bitmap_heap_scan") == 0)
+				return PGPA_TAG_BITMAP_HEAP_SCAN;
+			break;
+		case 'f':
+			if (strcmp(tag, "foreign_join") == 0)
+				return PGPA_TAG_FOREIGN_JOIN;
+			break;
+		case 'g':
+			if (strcmp(tag, "gather") == 0)
+				return PGPA_TAG_GATHER;
+			if (strcmp(tag, "gather_merge") == 0)
+				return PGPA_TAG_GATHER_MERGE;
+			break;
+		case 'h':
+			if (strcmp(tag, "hash_join") == 0)
+				return PGPA_TAG_HASH_JOIN;
+			break;
+		case 'i':
+			if (strcmp(tag, "index_scan") == 0)
+				return PGPA_TAG_INDEX_SCAN;
+			if (strcmp(tag, "index_only_scan") == 0)
+				return PGPA_TAG_INDEX_ONLY_SCAN;
+			break;
+		case 'j':
+			if (strcmp(tag, "join_order") == 0)
+				return PGPA_TAG_JOIN_ORDER;
+			break;
+		case 'm':
+			if (strcmp(tag, "merge_join_materialize") == 0)
+				return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+			if (strcmp(tag, "merge_join_plain") == 0)
+				return PGPA_TAG_MERGE_JOIN_PLAIN;
+			break;
+		case 'n':
+			if (strcmp(tag, "nested_loop_materialize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+			if (strcmp(tag, "nested_loop_memoize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+			if (strcmp(tag, "nested_loop_plain") == 0)
+				return PGPA_TAG_NESTED_LOOP_PLAIN;
+			if (strcmp(tag, "no_gather") == 0)
+				return PGPA_TAG_NO_GATHER;
+			break;
+		case 'p':
+			if (strcmp(tag, "partitionwise") == 0)
+				return PGPA_TAG_PARTITIONWISE;
+			break;
+		case 's':
+			if (strcmp(tag, "semijoin_non_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+			if (strcmp(tag, "semijoin_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_UNIQUE;
+			if (strcmp(tag, "seq_scan") == 0)
+				return PGPA_TAG_SEQ_SCAN;
+			break;
+		case 't':
+			if (strcmp(tag, "tid_scan") == 0)
+				return PGPA_TAG_TID_SCAN;
+			break;
+	}
+
+	/* didn't work out */
+	*fail = true;
+
+	/* return an arbitrary value to unwind the call stack */
+	return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		bool		first = true;
+		char	   *delims;
+
+		if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+			delims = "{}";
+		else
+			delims = "()";
+
+		appendStringInfoChar(str, delims[0]);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_advice_target(str, child_target);
+		}
+		appendStringInfoChar(str, delims[1]);
+	}
+	else
+	{
+		const char *rt_identifier;
+
+		rt_identifier = pgpa_identifier_string(&target->rid);
+		appendStringInfoString(str, rt_identifier);
+	}
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+	if (itarget->itype != PGPA_INDEX_NAME)
+	{
+		bool		first = true;
+
+		if (itarget->itype == PGPA_INDEX_AND)
+			appendStringInfoString(str, "&&(");
+		else
+			appendStringInfoString(str, "||(");
+
+		foreach_ptr(pgpa_index_target, child_target, itarget->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_index_target(str, child_target);
+		}
+		appendStringInfoChar(str, ')');
+	}
+	else
+	{
+		if (itarget->indnamespace != NULL)
+			appendStringInfo(str, "%s.",
+							 quote_identifier(itarget->indnamespace));
+		appendStringInfoString(str, quote_identifier(itarget->indname));
+	}
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+	if (i1->itype != i2->itype)
+		return false;
+
+	if (i1->itype == PGPA_INDEX_NAME)
+	{
+		/* indnamespace can be NULL, and two NULL values are equal */
+		if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+			(i1->indnamespace == NULL || i2->indnamespace == NULL ||
+			 strcmp(i1->indnamespace, i2->indnamespace) != 0))
+			return false;
+		if (strcmp(i1->indname, i2->indname) != 0)
+			return false;
+	}
+	else
+	{
+		int			i1_length = list_length(i1->children);
+
+		if (i1_length != list_length(i2->children))
+			return false;
+		for (int n = 0; n < i1_length; ++n)
+		{
+			pgpa_index_target *c1 = list_nth(i1->children, n);
+			pgpa_index_target *c2 = list_nth(i2->children, n);
+
+			if (!pgpa_index_targets_equal(c1, c2))
+				return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+	/* For non-identifiers, check all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (pgpa_identifier_matches_target(rid, child_target))
+				return true;
+		}
+		return false;
+	}
+
+	if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+		return false;
+	if (rid->occurrence != target->rid.occurrence)
+		return false;
+
+	/*
+	 * The identifier must specify a schema, but the target may leave the
+	 * schema NULL to match anything.
+	 */
+	if (target->rid.partnsp != NULL &&
+		strcmp(rid->partnsp, target->rid.partnsp) != 0)
+		return false;
+
+
+	/*
+	 * These fields can be NULL on either side, but NULL only matches another
+	 * NULL.
+	 */
+	if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+		return false;
+	if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+		return false;
+
+	return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target)
+{
+	bool		all_rids_used = true;
+	bool		any_rids_used = false;
+	bool		all_targets_used;
+	bool	   *rids_used = palloc0_array(bool, nrids);
+
+	all_targets_used =
+		pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+	for (int i = 0; i < nrids; ++i)
+	{
+		if (rids_used[i])
+			any_rids_used = true;
+		else
+			all_rids_used = false;
+	}
+
+	if (all_rids_used)
+	{
+		if (all_targets_used)
+			return PGPA_ITM_EQUAL;
+		else
+			return PGPA_ITM_KEYS_ARE_SUBSET;
+	}
+	else
+	{
+		if (all_targets_used)
+			return PGPA_ITM_TARGETS_ARE_SUBSET;
+		else if (any_rids_used)
+			return PGPA_ITM_INTERSECTING;
+		else
+			return PGPA_ITM_DISJOINT;
+	}
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target, bool *rids_used)
+{
+	bool		result = false;
+
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		result = true;
+
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+											   rids_used))
+				result = false;
+		}
+	}
+	else
+	{
+		for (int i = 0; i < nrids; ++i)
+		{
+			if (pgpa_identifier_matches_target(&rids[i], target))
+			{
+				rids_used[i] = true;
+				result = true;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
new file mode 100644
index 00000000000..f6fe730a4d4
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -0,0 +1,204 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ *	  abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+	PGPA_TARGET_IDENTIFIER,		/* relation identifier */
+	PGPA_TARGET_ORDERED_LIST,	/* (item ...) */
+	PGPA_TARGET_UNORDERED_LIST	/* {item ...} */
+} pgpa_target_type;
+
+/*
+ * When an advice item describes a bitmap index scan, it may need to describe
+ * the use of multiple indexes.
+ */
+typedef enum
+{
+	PGPA_INDEX_NAME,			/* index schema + name */
+	PGPA_INDEX_AND,				/* &&(item ...) */
+	PGPA_INDEX_OR				/* ||(item ...) */
+} pgpa_index_type;
+
+/*
+ * An index specification. We use this for INDEX_SCAN, INDEX_ONLY_SCAN,
+ * and BITMAP_HEAP_SCAN advice, but in the former two cases, the target must
+ * be of type PGPA_INDEX_NAME.
+ */
+typedef struct pgpa_index_target
+{
+	pgpa_index_type itype;
+
+	/* Index schem and name, when itype == PGPA_INDEX_NAME */
+	char	   *indnamespace;
+	char	   *indname;
+
+	/* List of pgpa_index_target objects, when itype != PGPA_INDEX_NAME */
+	List	   *children;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+	pgpa_target_type ttype;
+
+	/*
+	 * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+	 *
+	 * All identifiers must have an alias name and an occurrence number; the
+	 * remaining fields can be NULL. Note that it's possible to specify a
+	 * partition name without a partition schema, but not the reverse.
+	 */
+	pgpa_identifier rid;
+
+	/*
+	 * This field is set when ttype is PPGA_TARGET_IDENTIFIER and the advice
+	 * tag is PGPA_TAG_INDEX_SCAN, PGPA_TAG_INDEX_ONLY_SCAN, or
+	 * PGPA_TAG_BITMAP_HEAP_SCAN.
+	 */
+	pgpa_index_target *itarget;
+
+	/*
+	 * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+	 * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+	 */
+	List	   *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_FOREIGN_JOIN,
+	PGPA_TAG_GATHER,
+	PGPA_TAG_GATHER_MERGE,
+	PGPA_TAG_HASH_JOIN,
+	PGPA_TAG_INDEX_ONLY_SCAN,
+	PGPA_TAG_INDEX_SCAN,
+	PGPA_TAG_JOIN_ORDER,
+	PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+	PGPA_TAG_MERGE_JOIN_PLAIN,
+	PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+	PGPA_TAG_NESTED_LOOP_MEMOIZE,
+	PGPA_TAG_NESTED_LOOP_PLAIN,
+	PGPA_TAG_NO_GATHER,
+	PGPA_TAG_PARTITIONWISE,
+	PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+	PGPA_TAG_SEMIJOIN_UNIQUE,
+	PGPA_TAG_SEQ_SCAN,
+	PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+	pgpa_advice_tag_type tag;
+	List	   *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by an
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifeirs and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifeirs and targets were
+ * found.
+ */
+typedef enum
+{
+	PGPA_ITM_EQUAL,
+	PGPA_ITM_KEYS_ARE_SUBSET,
+	PGPA_ITM_TARGETS_ARE_SUBSET,
+	PGPA_ITM_INTERSECTING,
+	PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int	pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+					   char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner,
+						 const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int	pgpa_yyparse(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+										   pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+												   pgpa_identifier *rids,
+												   pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+									 pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+									  pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+									 pgpa_index_target *itarget);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_collector.c b/contrib/pg_plan_advice/pgpa_collector.c
new file mode 100644
index 00000000000..12085d9d75f
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.c
@@ -0,0 +1,637 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.c
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgpa_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgpa_collected_advice;
+
+/*
+ * A bunch of pointers to pgpa_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgpa_local_advice_chunk
+{
+	pgpa_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgpa_local_advice_chunk;
+
+/*
+ * Information about all of the pgpa_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgpa_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgpa_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgpa_local_advice_chunk **chunks;
+} pgpa_local_advice;
+
+/*
+ * Just like pgpa_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgpa_shared_advice_chunk;
+
+/*
+ * Just like pgpa_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgpa_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgpa_local_advice *local_collector = NULL;
+static pgpa_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgpa_collected_advice *pgpa_make_collected_advice(Oid userid,
+														 Oid dbid,
+														 uint64 queryId,
+														 TimestampTz timestamp,
+														 const char *query_string,
+														 const char *advice_string,
+														 dsa_area *area,
+														 dsa_pointer *result);
+static void pgpa_store_local_advice(pgpa_collected_advice *ca);
+static void pgpa_trim_local_advice(int limit);
+static void pgpa_store_shared_advice(dsa_pointer ca_pointer);
+static void pgpa_trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgpa_collected_advice */
+static inline const char *
+query_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgpa_collected_advice */
+static inline const char *
+advice_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pgpa_collect_advice(uint64 queryId, const char *query_string,
+					const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_plan_advice_local_collection_limit > 0)
+	{
+		pgpa_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+		ca = pgpa_make_collected_advice(userid, dbid, queryId, now,
+										query_string, advice_string,
+										NULL, NULL);
+		pgpa_store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_plan_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_plan_advice_dsa_area();
+		dsa_pointer ca_pointer;
+
+		pgpa_make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string, area,
+								   &ca_pointer);
+		pgpa_store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgpa_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgpa_collected_advice *
+pgpa_make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+						   TimestampTz timestamp,
+						   const char *query_string,
+						   const char *advice_string,
+						   dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgpa_collected_advice *ca;
+
+	total_length = offsetof(pgpa_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = GetUserId();
+	ca->dbid = MyDatabaseId;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pg_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+pgpa_store_local_advice(pgpa_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgpa_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgpa_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgpa_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgpa_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_local_advice(pg_plan_advice_local_collection_limit);
+}
+
+/*
+ * Add a pg_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_plan_advice DSA area
+ * and should point to an object of type pgpa_collected_advice.
+ */
+static void
+pgpa_store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	pgpa_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgpa_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgpa_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_shared_advice(area, pg_plan_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_local_advice(int limit)
+{
+	pgpa_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgpa_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgpa_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_shared_advice(dsa_area *area, int limit)
+{
+	pgpa_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(pgpa_shared_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		pgpa_trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	pgpa_trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice *sa = shared_collector;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_plan_advice/pgpa_collector.h b/contrib/pg_plan_advice/pgpa_collector.h
new file mode 100644
index 00000000000..b6e746a06d7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.h
@@ -0,0 +1,18 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.h
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_COLLECTOR_H
+#define PGPA_COLLECTOR_H
+
+extern void pgpa_collect_advice(uint64 queryId, const char *query_string,
+								const char *advice_string);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_identifier.c b/contrib/pg_plan_advice/pgpa_identifier.c
new file mode 100644
index 00000000000..2fa8075d66e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.c
@@ -0,0 +1,476 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ *	  create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted.  Identifier-style escaping is applied to alias_name and
+ * subquery_name.  Whenever we include child_table_name, we always
+ * schema-qualified name, but writing their own plan advice are not required
+ * to do so.  Identifier-style escaping is applied to the schema and to the
+ * relation names separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+									  List *appinfos);
+static int	pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+								   SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+	const char *result;
+
+	Assert(rid->alias_name != NULL);
+	result = quote_identifier(rid->alias_name);
+
+	Assert(rid->occurrence >= 0);
+	if (rid->occurrence > 1)
+		result = psprintf("%s#%d", result, rid->occurrence);
+
+	if (rid->partrel != NULL)
+	{
+		if (rid->partnsp == NULL)
+			result = psprintf("%s/%s", result,
+							  quote_identifier(rid->partnsp));
+		else
+			result = psprintf("%s/%s.%s", result,
+							  quote_identifier(rid->partnsp),
+							  quote_identifier(rid->partrel));
+	}
+
+	if (rid->plan_name != NULL)
+		result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+	return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+							   pgpa_identifier *rid)
+{
+	Index		top_rti = rti;
+	int			occurrence = 1;
+	RangeTblEntry *rte;
+	RangeTblEntry *top_rte;
+	char	   *partnsp = NULL;
+	char	   *partrel = NULL;
+
+	/*
+	 * If this is a child RTE, find the topmost parent that is still of type
+	 * RTE_RELATION. We do this because we identify children of partitioned
+	 * tables by the name of the child table, but subqueries can also have
+	 * child rels and we don't care about those here.
+	 */
+	for (;;)
+	{
+		AppendRelInfo *appinfo;
+		RangeTblEntry *parent_rte;
+
+		/* append_rel_array can be NULL if there are no children */
+		if (root->append_rel_array == NULL ||
+			(appinfo = root->append_rel_array[top_rti]) == NULL)
+			break;
+
+		parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+		if (parent_rte->rtekind != RTE_RELATION)
+			break;
+
+		top_rti = appinfo->parent_relid;
+	}
+
+	/* Get the range table entries for the RTI and top RTI. */
+	rte = planner_rt_fetch(rti, root);
+	top_rte = planner_rt_fetch(top_rti, root);
+	Assert(rte->rtekind != RTE_JOIN);
+	Assert(top_rte->rtekind != RTE_JOIN);
+
+	/* Work out the correct occurrence number. */
+	for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+		AppendRelInfo *appinfo;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 *
+		 * NB: append_rel_array can be NULL if there are no children
+		 */
+		if (root->append_rel_array != NULL &&
+			(appinfo = root->append_rel_array[prior_rti]) != NULL)
+		{
+			RangeTblEntry *parent_rte;
+
+			parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+			if (parent_rte->rtekind == RTE_RELATION)
+				continue;
+		}
+
+		/* Skip NULL entries and joins. */
+		prior_rte = planner_rt_fetch(prior_rti, root);
+		if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	/* If this is a child table, get the schema and relation names. */
+	if (rti != top_rti)
+	{
+		partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+		partrel = get_rel_name(rte->relid);
+	}
+
+	/* OK, we have all the answers we need. Return them to the caller. */
+	rid->alias_name = top_rte->eref->aliasname;
+	rid->occurrence = occurrence;
+	rid->partnsp = partnsp;
+	rid->partrel = partrel;
+	rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+								   pgpa_identifier *rids)
+{
+	int			count = 0;
+	int			rti = -1;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = planner_rt_fetch(rti, root);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+		pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+	}
+
+	Assert(count > 0);
+	return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+	Index		rtable_length = list_length(pstmt->rtable);
+	pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+	Index	   *top_rti_map;
+	int			rtinfoindex = 0;
+	SubPlanRTInfo *rtinfo = NULL;
+	SubPlanRTInfo *nextrtinfo = NULL;
+
+	/*
+	 * Account for relations addded by inheritance expansion of partitioned
+	 * tables.
+	 */
+	top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+										  pstmt->appendRelations);
+
+	/*
+	 * When we begin iterating, we're processing the portion of the range
+	 * table that originated from the top-level PlannerInfo, so subrtinfo is
+	 * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+	 * portion of the range table we are processing. nextrtinfo is always the
+	 * SubPlanRTInfo that follows the current one, if any, so when we're
+	 * processing the top-level query's portion of the range table, the next
+	 * SubPlanRTInfo is the very first one.
+	 */
+	if (pstmt->subrtinfos != NULL)
+		nextrtinfo = linitial(pstmt->subrtinfos);
+
+	/* Main loop over the range table. */
+	for (Index rti = 1; rti <= rtable_length; rti++)
+	{
+		const char *plan_name;
+		Index		top_rti;
+		RangeTblEntry *rte;
+		RangeTblEntry *top_rte;
+		char	   *partnsp = NULL;
+		char	   *partrel = NULL;
+		int			occurrence;
+		pgpa_identifier *rid;
+
+		/*
+		 * Advance to the next SubPlanRTInfo, if it's time to do that.
+		 *
+		 * This loop probably shouldn't ever iterate more than once, because
+		 * that would imply that a subquery was planned but added nothing to
+		 * the range table; but let's be defensive and assume it can happen.
+		 */
+		while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+		{
+			rtinfo = nextrtinfo;
+			if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+				nextrtinfo = NULL;
+			else
+				nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+		}
+
+		/* Fetch the range table entry, if any. */
+		rte = rt_fetch(rti, pstmt->rtable);
+
+		/*
+		 * We can't and don't need to identify null entries, and we don't want
+		 * to identify join entries.
+		 */
+		if (rte == NULL || rte->rtekind == RTE_JOIN)
+			continue;
+
+		/*
+		 * If this is not a relation added by partitioned table expansion,
+		 * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+		 * we need the information for the top RTI/RTE, and must also fetch
+		 * the partition schema and name.
+		 */
+		top_rti = top_rti_map[rti - 1];
+		if (rti == top_rti)
+			top_rte = rte;
+		else
+		{
+			top_rte = rt_fetch(top_rti, pstmt->rtable);
+			partnsp =
+				get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+			partrel = get_rel_name(rte->relid);
+		}
+
+		/* Compute the correct occurrence number. */
+		occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+											rtinfo, top_rti);
+
+		/* Get the name of the current plan (NULL for toplevel query). */
+		plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+		/* Save all the details we've derived. */
+		rid = &result[rti - 1];
+		rid->alias_name = top_rte->eref->aliasname;
+		rid->occurrence = occurrence;
+		rid->partnsp = partnsp;
+		rid->partrel = partrel;
+		rid->plan_name = plan_name;
+	}
+
+	return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid)
+{
+	Index		result = 0;
+
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+	{
+		pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+		/* If there's no identifier for this RTI, skip it. */
+		if (rti_rid->alias_name == NULL)
+			continue;
+
+		/*
+		 * If it matches, return this RTI. As usual, an omitted partition
+		 * schema matches anything, but partition and plan names must either
+		 * match exactly or be omitted on both sides.
+		 */
+		if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+			rid->occurrence == rti_rid->occurrence &&
+			(rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+			 strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+			strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+			strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+		{
+			if (result != 0)
+			{
+				/* Multiple matches were found. */
+				return 0;
+			}
+			result = rti;
+		}
+	}
+
+	return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+	Index	   *top_rti_map = palloc0_array(Index, rtable_length);
+
+	/* Initially, make every RTI point to itself. */
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+		top_rti_map[rti - 1] = rti;
+
+	/* Update the map for each AppendRelInfo object. */
+	foreach_node(AppendRelInfo, appinfo, appinfos)
+	{
+		Index		parent_rti = appinfo->parent_relid;
+		RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+		/* If the parent is not RTE_RELATION, ignore this entry. */
+		if (parent_rte->rtekind != RTE_RELATION)
+			continue;
+
+		/*
+		 * Map the child to wherever we mapped the parent. Parents always
+		 * precede their children in the AppendRelInfo list, so this should
+		 * work out.
+		 */
+		top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+	}
+
+	return top_rti_map;
+}
+
+/*
+ * Find the occurence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+					   SubPlanRTInfo *rtinfo, Index rti)
+{
+	Index		rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+	int			occurrence = 1;
+	RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+	for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 */
+		if (top_rti_map[prior_rti - 1] != prior_rti)
+			break;
+
+		/* Skip joins. */
+		prior_rte = rt_fetch(prior_rti, rtable);
+		if (prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	return occurrence;
+}
diff --git a/contrib/pg_plan_advice/pgpa_identifier.h b/contrib/pg_plan_advice/pgpa_identifier.h
new file mode 100644
index 00000000000..b000d2b7081
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ *	  create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+	const char *alias_name;
+	int			occurrence;
+	const char *partnsp;
+	const char *partrel;
+	const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+	if (a == b)
+		return true;
+	else if (a == NULL || b == NULL)
+		return false;
+	else
+		return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+										   pgpa_identifier *rid);
+extern int	pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+											   Bitmapset *relids,
+											   pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+											  pgpa_identifier *rt_identifiers,
+											  pgpa_identifier *rid);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
new file mode 100644
index 00000000000..28618764d86
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -0,0 +1,615 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	Plan	   *outer_subplan;
+	ElidedNode *outer_elided_node;
+	bool		outer_beneath_any_gather;
+	pgpa_join_strategy *strategy;
+	Plan	  **inner_subplans;
+	ElidedNode **inner_elided_nodes;
+	pgpa_join_unroller **inner_unrollers;
+	bool	   *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+											  Plan *plan,
+											  Plan **realouter,
+											  Plan **realinner,
+											  ElidedNode **elidedrealouter,
+											  ElidedNode **elidedrealinner,
+											  bool *found_any_outer_gather,
+											  bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+										   bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+									ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+	pgpa_join_unroller *join_unroller;
+
+	join_unroller = palloc0_object(pgpa_join_unroller);
+	join_unroller->nallocated = 4;
+	join_unroller->strategy =
+		palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+	join_unroller->inner_subplans =
+		palloc_array(Plan *, join_unroller->nallocated);
+	join_unroller->inner_elided_nodes =
+		palloc_array(ElidedNode *, join_unroller->nallocated);
+	join_unroller->inner_unrollers =
+		palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+	join_unroller->inner_beneath_any_gather =
+		palloc_array(bool, join_unroller->nallocated);
+
+	return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+				 bool beneath_any_gather,
+				 pgpa_join_unroller *join_unroller,
+				 pgpa_join_unroller **outer_join_unroller,
+				 pgpa_join_unroller **inner_join_unroller)
+{
+	pgpa_join_strategy strategy;
+	Plan	   *realinner,
+			   *realouter;
+	ElidedNode *elidedinner,
+			   *elidedouter;
+	int			n;
+	bool		found_any_outer_gather = false;
+	bool		found_any_inner_gather = false;
+
+	Assert(join_unroller != NULL);
+
+	/*
+	 * We need to pass the join_unroller object down through certain types of
+	 * plan nodes -- anything that's considered part of the join strategy, and
+	 * any other nodes that can occur in a join tree despite not being scans
+	 * or joins.
+	 *
+	 * This includes:
+	 *
+	 * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+	 * strategy,
+	 *
+	 * (2) Gather and Gather Merge nodes, which can occur at any point in the
+	 * join tree where the planner decided to initiate parallelism,
+	 *
+	 * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+	 * or GatherMerge,
+	 *
+	 * (4) Agg and Unique nodes, which can occur when we decide to make the
+	 * nullable side of a semijoin unique and then join the result, and
+	 *
+	 * (5) Result nodes with children, which can be added either to project to
+	 * enforce a one-time filter (but Result nodes without children are
+	 * degenerate scans or joins).
+	 */
+	if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+		|| IsA(plan, Gather) || IsA(plan, GatherMerge)
+		|| is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+		|| is_result_node_with_child(plan))
+	{
+		*outer_join_unroller = join_unroller;
+		return;
+	}
+
+	/*
+	 * Since we've already handled nodes that require pass-through treatment,
+	 * this should be an unrollable join.
+	 */
+	strategy = pgpa_decompose_join(walker, plan,
+								   &realouter, &realinner,
+								   &elidedouter, &elidedinner,
+								   &found_any_outer_gather,
+								   &found_any_inner_gather);
+
+	/* If our workspace is full, expand it. */
+	if (join_unroller->nused >= join_unroller->nallocated)
+	{
+		join_unroller->nallocated *= 2;
+		join_unroller->strategy =
+			repalloc_array(join_unroller->strategy,
+						   pgpa_join_strategy,
+						   join_unroller->nallocated);
+		join_unroller->inner_subplans =
+			repalloc_array(join_unroller->inner_subplans,
+						   Plan *,
+						   join_unroller->nallocated);
+		join_unroller->inner_elided_nodes =
+			repalloc_array(join_unroller->inner_elided_nodes,
+						   ElidedNode *,
+						   join_unroller->nallocated);
+		join_unroller->inner_beneath_any_gather =
+			repalloc_array(join_unroller->inner_beneath_any_gather,
+						   bool,
+						   join_unroller->nallocated);
+		join_unroller->inner_unrollers =
+			repalloc_array(join_unroller->inner_unrollers,
+						   pgpa_join_unroller *,
+						   join_unroller->nallocated);
+	}
+
+	/*
+	 * Since we're flattening outer-deep join trees, it follows that if the
+	 * outer side is still an unrollable join, it should be unrolled into this
+	 * same object. Otherwise, we've reached the limit of what we can unroll
+	 * into this object and must remember the outer side as the final outer
+	 * subplan.
+	 */
+	if (elidedouter == NULL && pgpa_is_join(realouter))
+		*outer_join_unroller = join_unroller;
+	else
+	{
+		join_unroller->outer_subplan = realouter;
+		join_unroller->outer_elided_node = elidedouter;
+		join_unroller->outer_beneath_any_gather =
+			beneath_any_gather || found_any_outer_gather;
+	}
+
+	/*
+	 * Store the inner subplan. If it's an unrollable join, it needs to be
+	 * flattened in turn, but into a new unroller object, not this one.
+	 */
+	n = join_unroller->nused++;
+	join_unroller->strategy[n] = strategy;
+	join_unroller->inner_subplans[n] = realinner;
+	join_unroller->inner_elided_nodes[n] = elidedinner;
+	join_unroller->inner_beneath_any_gather[n] =
+		beneath_any_gather || found_any_inner_gather;
+	if (elidedinner == NULL && pgpa_is_join(realinner))
+		*inner_join_unroller = pgpa_create_join_unroller();
+	else
+		*inner_join_unroller = NULL;
+	join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+						 pgpa_join_unroller *join_unroller)
+{
+	pgpa_unrolled_join *ujoin;
+	int			i;
+
+	/*
+	 * We shouldn't have gone even so far as to create a join unroller unless
+	 * we found at least one unrollable join.
+	 */
+	Assert(join_unroller->nused > 0);
+
+	/* Allocate result structures. */
+	ujoin = palloc0_object(pgpa_unrolled_join);
+	ujoin->ninner = join_unroller->nused;
+	ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+	ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+	/* Handle the outermost join. */
+	ujoin->outer.plan = join_unroller->outer_subplan;
+	ujoin->outer.elided_node = join_unroller->outer_elided_node;
+	ujoin->outer.scan =
+		pgpa_build_scan(walker, ujoin->outer.plan,
+						ujoin->outer.elided_node,
+						join_unroller->outer_beneath_any_gather,
+						true);
+
+	/*
+	 * We want the joins from the deepest part of the plan tree to appear
+	 * first in the result object, but the join unroller adds them in exactly
+	 * the reverse of that order, so we need to flip the order of the arrays
+	 * when constructing the final result.
+	 */
+	for (i = 0; i < join_unroller->nused; ++i)
+	{
+		int			k = join_unroller->nused - i - 1;
+
+		/* Copy strategy, Plan, and ElidedNode. */
+		ujoin->strategy[i] = join_unroller->strategy[k];
+		ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+		ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+		/*
+		 * Fill in remaining details, using either the nested join unroller,
+		 * or by deriving them from the plan and elided nodes.
+		 */
+		if (join_unroller->inner_unrollers[k] != NULL)
+			ujoin->inner[i].unrolled_join =
+				pgpa_build_unrolled_join(walker,
+										 join_unroller->inner_unrollers[k]);
+		else
+			ujoin->inner[i].scan =
+				pgpa_build_scan(walker, ujoin->inner[i].plan,
+								ujoin->inner[i].elided_node,
+								join_unroller->inner_beneath_any_gather[i],
+								true);
+	}
+
+	return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+	pfree(join_unroller->strategy);
+	pfree(join_unroller->inner_subplans);
+	pfree(join_unroller->inner_elided_nodes);
+	pfree(join_unroller->inner_unrollers);
+	pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort note on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any correspoding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+					Plan **realouter, Plan **realinner,
+					ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+					bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+	PlannedStmt *pstmt = walker->pstmt;
+	JoinType	jointype = ((Join *) plan)->jointype;
+	Plan	   *outerplan = plan->lefttree;
+	Plan	   *innerplan = plan->righttree;
+	ElidedNode *elidedouter;
+	ElidedNode *elidedinner;
+	pgpa_join_strategy strategy;
+	bool		uniqueouter;
+	bool		uniqueinner;
+
+	elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+	elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+	*found_any_outer_gather = false;
+	*found_any_inner_gather = false;
+
+	switch (nodeTag(plan))
+	{
+		case T_MergeJoin:
+
+			/*
+			 * The planner may have chosen to place a Material node on the
+			 * inner side of the MergeJoin; if this is present, we record it
+			 * as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+			}
+			else
+				strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+			/*
+			 * For a MergeJoin, either the outer or the inner subplan, or
+			 * both, may have needed to be sorted; we must disregard any Sort
+			 * or IncrementalSort node to find the real inner or outer
+			 * subplan.
+			 */
+			if (elidedouter == NULL && is_sorting_plan(outerplan))
+				elidedouter = pgpa_descend_node(pstmt, &outerplan);
+			if (elidedinner == NULL && is_sorting_plan(innerplan))
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			break;
+
+		case T_NestLoop:
+
+			/*
+			 * The planner may have chosen to place a Material or Memoize node
+			 * on the inner side of the NestLoop; if this is present, we
+			 * record it as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+			}
+			else if (elidedinner == NULL && IsA(innerplan, Memoize))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+			}
+			else
+				strategy = JSTRAT_NESTED_LOOP_PLAIN;
+			break;
+
+		case T_HashJoin:
+
+			/*
+			 * The inner subplan of a HashJoin is always a Hash node; the real
+			 * inner subplan is the Hash node's child.
+			 */
+			Assert(IsA(innerplan, Hash));
+			Assert(elidedinner == NULL);
+			elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			strategy = JSTRAT_HASH_JOIN;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+	}
+
+	/*
+	 * The planner may have decided to implement a semijoin by first making
+	 * the nullable side of the plan unique, and then performing a normal join
+	 * against the result. Therefore, we might need to descend through a
+	 * unique node on either side of the plan.
+	 */
+	uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+	uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+	/*
+	 * The planner may have decided to parallelize part of the join tree, so
+	 * we could find a Gather or Gather Merge node here. Note that, if
+	 * present, this will appear below nodes we considered as part of the join
+	 * strategy, but we could find another uniqueness-enforcing node below the
+	 * Gather or Gather Merge, if present.
+	 */
+	if (elidedouter == NULL)
+	{
+		elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+											  found_any_outer_gather);
+		if (found_any_outer_gather &&
+			pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+			uniqueouter = true;
+	}
+	if (elidedinner == NULL)
+	{
+		elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+											  found_any_inner_gather);
+		if (found_any_inner_gather &&
+			pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+			uniqueinner = true;
+	}
+
+	/*
+	 * It's possible that Result node has been inserted either to project a
+	 * target list or to implement a one-time filter. If so, we can descend
+	 * throught it. Note that a result node without a child would be a
+	 * degenerate scan or join, and not something we could descend through.
+	 *
+	 * XXX. I suspect it's possible for this to happen above the Gather or
+	 * Gather Merge node, too, but apparently we have no test case for that
+	 * scenario.
+	 */
+	if (elidedouter == NULL && is_result_node_with_child(outerplan))
+		elidedouter = pgpa_descend_node(pstmt, &outerplan);
+	if (elidedinner == NULL && is_result_node_with_child(innerplan))
+		elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+	/*
+	 * If this is a semijoin that was converted to an inner join by making one
+	 * side or the other unique, make a note that the inner or outer subplan,
+	 * as appropriate, should be treated as a query plan feature when the main
+	 * tree traversal reaches it.
+	 *
+	 * Conversely, if the planner could have made one side of the join unique
+	 * and thereby converted it to an inner join, and chose not to do so, that
+	 * is also worth noting.
+	 *
+	 * XXX: We admit too much non-unique advice, as in the following example
+	 * from the regression tests: EXPLAIN (PLAN_ADVICE, COSTS OFF) DELETE FROM
+	 * prt1_l WHERE EXISTS (SELECT 1 FROM int4_tbl, LATERAL (SELECT
+	 * int4_tbl.f1 FROM int8_tbl LIMIT 2) ss WHERE prt1_l.c IS NULL). We emit
+	 * SEMIJOIN_NON_UNIQUE((int4_tbl ss)) but create_unique_path() fails in
+	 * this case, so there's no sj-unique version possible.
+	 *
+	 * NB: This code could appear slightly higher up in in this function, but
+	 * none of the nodes through which we just descended should be have
+	 * associated RTIs.
+	 *
+	 * NB: This seems like a somewhat hacky way of passing information up to
+	 * the main tree walk, but I don't currently have a better idea.
+	 */
+	if (uniqueouter)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+	else if (jointype == JOIN_RIGHT_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+	if (uniqueinner)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+	else if (jointype == JOIN_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+	/* Set output parameters. */
+	*realouter = outerplan;
+	*realinner = innerplan;
+	*elidedrealouter = elidedouter;
+	*elidedrealinner = elidedinner;
+	return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+	*plan = (*plan)->lefttree;
+	return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+						bool *found_any_gather)
+{
+	if (IsA(*plan, Gather))
+	{
+		*found_any_gather = true;
+		return pgpa_descend_node(pstmt, plan);
+	}
+
+	if (IsA(*plan, GatherMerge))
+	{
+		ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+		if (elided == NULL && is_sorting_plan(*plan))
+			elided = pgpa_descend_node(pstmt, plan);
+
+		*found_any_gather = true;
+		return elided;
+	}
+
+	return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through at least one node, and
+ * otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+						ElidedNode **elided_node)
+{
+	if (*elided_node != NULL)
+		return false;
+
+	if (IsA(*plan, Agg) || IsA(*plan, Unique))
+	{
+		*elided_node = pgpa_descend_node(pstmt, plan);
+
+		if (*elided_node == NULL && is_sorting_plan(*plan))
+			*elided_node = pgpa_descend_node(pstmt, plan);
+
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+	return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+	return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
diff --git a/contrib/pg_plan_advice/pgpa_join.h b/contrib/pg_plan_advice/pgpa_join.h
new file mode 100644
index 00000000000..4dc72986a70
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.h
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+	JSTRAT_MERGE_JOIN_PLAIN = 0,
+	JSTRAT_MERGE_JOIN_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_PLAIN,
+	JSTRAT_NESTED_LOOP_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_MEMOIZE,
+	JSTRAT_HASH_JOIN
+	/* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY		((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+	Plan	   *plan;
+	ElidedNode *elided_node;
+	struct pgpa_scan *scan;
+	pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>.  When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+	/* Outermost member; must not itself be an unrolled join. */
+	pgpa_join_member outer;
+
+	/* Number of inner members. Length of the strategy and inner arrays. */
+	unsigned	ninner;
+
+	/* Array of strategies, one per non-outermost member. */
+	pgpa_join_strategy *strategy;
+
+	/* Array of members, excluding the outermost. Deepest first. */
+	pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+	return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+							 Plan *plan, bool beneath_any_gather,
+							 pgpa_join_unroller *join_unroller,
+							 pgpa_join_unroller **outer_join_unroller,
+							 pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+													pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
new file mode 100644
index 00000000000..89a675ff93e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -0,0 +1,628 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+	const char **rid_strings;
+	StringInfo	buf;
+	int			wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+									  pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+									pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+									  pgpa_scan_strategy strategy,
+									  List *scans);
+static void pgpa_output_bitmap_index_details(pgpa_output_context *context,
+											 Plan *plan);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+									  pgpa_qf_type type,
+									  List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+										char *strategy,
+										List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+								  Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+								  Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+				   pgpa_identifier *rt_identifiers)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	ListCell   *lc;
+	pgpa_output_context context;
+
+	/* Basic initialization. */
+	memset(&context, 0, sizeof(pgpa_output_context));
+	context.buf = buf;
+
+	/*
+	 * Convert identifiers to string form. Note that the loop variable here is
+	 * not an RTI, because RTIs are 1-based. Some RTIs will have no
+	 * identifier, either because the reloptkind is RTE_JOIN or because that
+	 * portion of the query didn't make it into the final plan.
+	 */
+	context.rid_strings = palloc0_array(const char *, rtable_length);
+	for (int i = 0; i < rtable_length; ++i)
+		if (rt_identifiers[i].alias_name != NULL)
+			context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+	/*
+	 * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+	 * from a psql client with default settings, psql will add one space to
+	 * the left of the output and EXPLAIN will add two more to the left of the
+	 * advice. Thus, lines of more than 77 characters will wrap. We set the
+	 * wrap limit to 76 here so that the output won't reach all the way to the
+	 * very last column of the terminal.
+	 *
+	 * Of course, this is fairly arbitrary set of assumptions, and one could
+	 * well make an argument for a different wrap limit, or for a configurable
+	 * one.
+	 */
+	context.wrap_column = 76;
+
+	/*
+	 * Each piece of JOIN_ORDER() advice fully describes the join order for a
+	 * a single unrolled join. Merging is not permitted, because that would
+	 * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+	 * scans should be used for all of those relations, and is thus equivalent
+	 * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+	 * is the driving table which is then joined to "b" then "c" then "d",
+	 * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+	 */
+	foreach(lc, walker->toplevel_unrolled_joins)
+	{
+		pgpa_unrolled_join *ujoin = lfirst(lc);
+
+		if (buf->len > 0)
+			appendStringInfoChar(buf, '\n');
+		appendStringInfo(context.buf, "JOIN_ORDER(");
+		pgpa_output_unrolled_join(&context, ujoin);
+		appendStringInfoChar(context.buf, ')');
+		pgpa_maybe_linebreak(context.buf, context.wrap_column);
+	}
+
+	/* Emit join strategy advice. */
+	for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+	{
+		char	   *strategy = pgpa_cstring_join_strategy(s);
+
+		pgpa_output_simple_strategy(&context,
+									strategy,
+									walker->join_strategies[s]);
+	}
+
+	/*
+	 * Emit scan strategy advice (but not for ordinary scans, which are
+	 * definitionally uninteresting).
+	 */
+	for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+		if (c != PGPA_SCAN_ORDINARY)
+			pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+	/* Emit query feature advice. */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+		pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+	/* Emit NO_GATHER advice. */
+	pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+						  pgpa_unrolled_join *join)
+{
+	pgpa_output_join_member(context, &join->outer);
+
+	for (int k = 0; k < join->ninner; ++k)
+	{
+		pgpa_join_member *member = &join->inner[k];
+
+		pgpa_maybe_linebreak(context->buf, context->wrap_column);
+		appendStringInfoChar(context->buf, ' ');
+		pgpa_output_join_member(context, member);
+	}
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+						pgpa_join_member *member)
+{
+	if (member->unrolled_join != NULL)
+	{
+		appendStringInfoChar(context->buf, '(');
+		pgpa_output_unrolled_join(context, member->unrolled_join);
+		appendStringInfoChar(context->buf, ')');
+	}
+	else
+	{
+		pgpa_scan  *scan = member->scan;
+
+		Assert(scan != NULL);
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '{');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, '}');
+		}
+	}
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+						  pgpa_scan_strategy strategy,
+						  List *scans)
+{
+	bool		first = true;
+
+	if (scans == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_scan_strategy(strategy));
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		Plan	   *plan = scan->plan;
+
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		/* Output the relation identifiers. */
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+
+		/* For scans involving indexes, output index information. */
+		if (strategy == PGPA_SCAN_INDEX)
+		{
+			Assert(IsA(plan, IndexScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_INDEX_ONLY)
+		{
+			Assert(IsA(plan, IndexOnlyScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context,
+									  ((IndexOnlyScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_BITMAP_HEAP)
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_bitmap_index_details(context, plan->lefttree);
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output information about which index or indexes power a BitmapHeapScan.
+ *
+ * We emit &&(i1 i2 i3) for a BitmapAnd between indexes i1, i2, and i3;
+ * and likewise ||(i1 i2 i3) for a similar BitmapOr operation.
+ */
+static void
+pgpa_output_bitmap_index_details(pgpa_output_context *context, Plan *plan)
+{
+	char	   *operator;
+	List	   *bitmapplans;
+	bool		first = true;
+
+	if (IsA(plan, BitmapIndexScan))
+	{
+		BitmapIndexScan *bitmapindexscan = (BitmapIndexScan *) plan;
+
+		pgpa_output_relation_name(context, bitmapindexscan->indexid);
+		return;
+	}
+
+	if (IsA(plan, BitmapOr))
+	{
+		operator = "||";
+		bitmapplans = ((BitmapOr *) plan)->bitmapplans;
+	}
+	else if (IsA(plan, BitmapAnd))
+	{
+		operator = "&&";
+		bitmapplans = ((BitmapAnd *) plan)->bitmapplans;
+	}
+	else
+		elog(ERROR, "unexpected node type: %d", (int) nodeTag(plan));
+
+	appendStringInfo(context->buf, "%s(", operator);
+	foreach_ptr(Plan, child_plan, bitmapplans)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+		pgpa_output_bitmap_index_details(context, child_plan);
+	}
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+	Oid			nspoid = get_rel_namespace(relid);
+	char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+	char	   *relname = get_rel_name(relid);
+
+	appendStringInfoString(context->buf, quote_identifier(relnamespace));
+	appendStringInfoChar(context->buf, '.');
+	appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+						  List *query_features)
+{
+	bool		first = true;
+
+	if (query_features == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_query_feature_type(type));
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(qf->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, qf->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, qf->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+							List *relid_sets)
+{
+	bool		first = true;
+
+	if (relid_sets == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(", strategy);
+
+	foreach_node(Bitmapset, relids, relid_sets)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+	if (relids == NULL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "NO_GATHER(");
+	pgpa_output_relations(context, context->buf, relids);
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+					  Bitmapset *relids)
+{
+	int			rti = -1;
+	bool		first = true;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		const char *rid_string = context->rid_strings[rti - 1];
+
+		if (rid_string == NULL)
+			elog(ERROR, "no identifier for RTI %d", rti);
+
+		if (first)
+		{
+			first = false;
+			appendStringInfoString(buf, rid_string);
+		}
+		else
+		{
+			pgpa_maybe_linebreak(buf, context->wrap_column);
+			appendStringInfo(buf, " %s", rid_string);
+		}
+	}
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+	switch (strategy)
+	{
+		case JSTRAT_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case JSTRAT_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case JSTRAT_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case JSTRAT_HASH_JOIN:
+			return "HASH_JOIN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+	switch (strategy)
+	{
+		case PGPA_SCAN_ORDINARY:
+			return "ORDINARY_SCAN";
+		case PGPA_SCAN_SEQ:
+			return "SEQ_SCAN";
+		case PGPA_SCAN_BITMAP_HEAP:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_SCAN_FOREIGN:
+			return "FOREIGN_JOIN";
+		case PGPA_SCAN_INDEX:
+			return "INDEX_SCAN";
+		case PGPA_SCAN_INDEX_ONLY:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_SCAN_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_SCAN_TID:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+	switch (type)
+	{
+		case PGPAQF_GATHER:
+			return "GATHER";
+		case PGPAQF_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPAQF_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPAQF_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+	}
+
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+	char	   *trailing_nl;
+	int			line_start;
+	int			save_cursor;
+
+	/* If line wrapping is disabled, exit quickly. */
+	if (wrap_column <= 0)
+		return;
+
+	/*
+	 * Set line_start to the byte offset within buf->data of the first
+	 * character of the current line, where the current line means the last
+	 * one in the buffer. Note that line_start could be the offset of the
+	 * trailing '\0' if the last character in the buffer is a line break.
+	 */
+	trailing_nl = strrchr(buf->data, '\n');
+	if (trailing_nl == NULL)
+		line_start = 0;
+	else
+		line_start = (trailing_nl - buf->data) + 1;
+
+	/*
+	 * Remember that the current end of the buffer is a potential location to
+	 * insert a line break on a future call to this function.
+	 */
+	save_cursor = buf->cursor;
+	buf->cursor = buf->len;
+
+	/* If we haven't passed the wrap column, we don't need a newline. */
+	if (buf->len - line_start <= wrap_column)
+		return;
+
+	/*
+	 * It only makes sense to insert a newline at a position later than the
+	 * beginning of the current line.
+	 */
+	if (buf->cursor <= line_start)
+		return;
+
+	/* Insert a newline at the previous cursor location. */
+	enlargeStringInfo(buf, 1);
+	memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+			buf->len - save_cursor);
+	++buf->cursor;
+	buf->data[++buf->len] = '\0';
+	buf->data[save_cursor] = '\n';
+}
diff --git a/contrib/pg_plan_advice/pgpa_output.h b/contrib/pg_plan_advice/pgpa_output.h
new file mode 100644
index 00000000000..47496d76f52
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+							   pgpa_plan_walker_context *walker,
+							   pgpa_identifier *rt_identifiers);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_parser.y b/contrib/pg_plan_advice/pgpa_parser.y
new file mode 100644
index 00000000000..4617e7f2f64
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_parser.y
@@ -0,0 +1,337 @@
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc.  This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE   pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+	char	   *str;
+	int			integer;
+	List	   *list;
+	pgpa_advice_item *item;
+	pgpa_advice_target *target;
+	pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_BITMAP TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+%token TOK_OR TOK_AND
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list bitmap_sublist bitmap_target_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> bitmap_target_item index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+		{
+			(void) yynerrs;				/* suppress compiler warning */
+			*result = $1;
+		}
+	;
+
+advice_item_list: advice_item_list advice_item
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_JOIN_ORDER;
+			$$->targets = $3;
+		}
+	| TOK_TAG_INDEX '(' index_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "index_only_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+			else if (strcmp($1, "index_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_BITMAP '(' bitmap_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+			$$->targets = $3;
+		}
+	| TOK_TAG_SIMPLE '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "no_gather") == 0)
+				$$->tag = PGPA_TAG_NO_GATHER;
+			else if (strcmp($1, "seq_scan") == 0)
+				$$->tag = PGPA_TAG_SEQ_SCAN;
+			else if (strcmp($1, "tid_scan") == 0)
+				$$->tag = PGPA_TAG_TID_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_GENERIC '(' generic_target_list ')'
+		{
+			bool	fail;
+
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = pgpa_parse_advice_tag($1, &fail);
+			if (fail)
+			{
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "unrecognized advice tag");
+			}
+
+			if ($$->tag == PGPA_TAG_FOREIGN_JOIN)
+			{
+				foreach_ptr(pgpa_advice_target, target, $3)
+				{
+					if (target->ttype == PGPA_TARGET_IDENTIFIER ||
+						list_length(target->children) == 1)
+							pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+										 "FOREIGN_JOIN targets must contain more than one relation identifier");
+				}
+			}
+
+			$$->targets = $3;
+		}
+	;
+
+relation_identifier: identifier opt_ri_occurrence opt_partition opt_plan_name
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_IDENTIFIER;
+			$$->rid.alias_name = $1;
+			$$->rid.occurrence = $2;
+			if (list_length($3) == 2)
+			{
+				$$->rid.partnsp = linitial($3);
+				$$->rid.partrel = lsecond($3);
+			}
+			else if ($3 != NIL)
+				$$->rid.partrel = linitial($3);
+			$$->rid.plan_name = $4;
+		}
+	;
+
+index_name: identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indname = $1;
+		}
+	| identifier '.' identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indnamespace = $1;
+			$$->indname = $3;
+		}
+	;
+
+opt_ri_occurrence:
+	'#' TOK_INTEGER
+		{
+			if ($2 <= 0)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "only positive occurrence numbers are permitted");
+			$$ = $2;
+		}
+	|
+		{
+			/* The default occurrence number is 1. */
+			$$ = 1;
+		}
+	;
+
+identifier: TOK_IDENT
+	| TOK_TAG_JOIN_ORDER
+	| TOK_TAG_INDEX
+	| TOK_TAG_BITMAP
+	| TOK_TAG_SIMPLE
+	| TOK_TAG_GENERIC
+	;
+
+/*
+ * When generating advice, we always schema-qualify the partition name, but
+ * when parsing advice, we accept a specification that lacks one.
+ */
+opt_partition:
+	'/' TOK_IDENT '.' TOK_IDENT
+		{ $$ = list_make2($2, $4); }
+	| '/' TOK_IDENT
+		{ $$ = list_make1($2); }
+	|
+		{ $$ = NIL; }
+	;
+
+opt_plan_name:
+	'@' TOK_IDENT
+		{ $$ = $2; }
+	|
+		{ $$ = NULL; }
+	;
+
+bitmap_target_list: bitmap_target_list relation_identifier bitmap_target_item
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+bitmap_target_item: index_name
+		{ $$ = $1; }
+	| TOK_OR '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_OR;
+			$$->children = $3;
+		}
+	| TOK_AND '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_AND;
+			$$->children = $3;
+		}
+	;
+
+bitmap_sublist: bitmap_sublist bitmap_target_item
+		{ $$ = lappend($1, $2); }
+	| bitmap_target_item
+		{ $$ = list_make1($1); }
+	;
+
+generic_target_list: generic_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| generic_target_list generic_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+generic_sublist: '(' generic_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+index_target_list:
+	  index_target_list relation_identifier index_name
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_target_list: join_order_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| join_order_target_list join_order_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_sublist:
+	'(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	| '{' simple_target_list '}'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_UNORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+simple_target_list: simple_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+%%
+
+/*
+ * Parse an advice_string and return the resulting list of pgpa_advice_item
+ * objects. If a parse error occurs, instead return NULL.
+ *
+ * If the return value is NULL, *error_p will be set to the error message;
+ * otherwise, *error_p will be set to NULL.
+ */
+List *
+pgpa_parse(const char *advice_string, char **error_p)
+{
+	yyscan_t	scanner;
+	List	   *result;
+	char	   *error = NULL;
+
+	pgpa_scanner_init(advice_string, &scanner);
+	pgpa_yyparse(&result, &error, scanner);
+	pgpa_scanner_finish(scanner);
+
+	if (error != NULL)
+	{
+		*error_p = error;
+		return NULL;
+	}
+
+	*error_p = NULL;
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
new file mode 100644
index 00000000000..bf1eda3b8f7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -0,0 +1,1706 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.c
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "common/hashfn_unstable.h"
+#include "nodes/makefuncs.h"
+#include "optimizer/extendplan.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/plancat.h"
+#include "optimizer/planner.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * When assertions are enabled, we try generating relation identifiers during
+ * planning, saving them in a hash table, and then cross-checking them against
+ * the ones generated after planning is complete.
+ */
+typedef struct pgpa_ri_checker_key
+{
+	char	   *plan_name;
+	Index		rti;
+} pgpa_ri_checker_key;
+
+typedef struct pgpa_ri_checker
+{
+	pgpa_ri_checker_key key;
+	uint32		status;
+	const char *rid_string;
+} pgpa_ri_checker;
+
+static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
+
+static inline bool
+pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
+{
+	if (a.rti != b.rti)
+		return false;
+	if (a.plan_name == NULL)
+		return (b.plan_name == NULL);
+	if (b.plan_name == NULL)
+		return false;
+	return strcmp(a.plan_name, b.plan_name) == 0;
+}
+
+#define SH_PREFIX			pgpa_ri_check
+#define SH_ELEMENT_TYPE		pgpa_ri_checker
+#define SH_KEY_TYPE			pgpa_ri_checker_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#endif
+
+typedef struct pgpa_planner_state
+{
+	ExplainState *explain_state;
+	pgpa_trove *trove;
+	MemoryContext trove_cxt;
+
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_check_hash *ri_check_hash;
+#endif
+} pgpa_planner_state;
+
+typedef struct pgpa_join_state
+{
+	/* Most-recently-considered outer rel. */
+	RelOptInfo *outerrel;
+
+	/* Most-recently-considered inner rel. */
+	RelOptInfo *innerrel;
+
+	/*
+	 * Array of relation identifiers for all members of this joinrel, with
+	 * outerrel idenifiers before innerrel identifiers.
+	 */
+	pgpa_identifier *rids;
+
+	/* Number of outer rel identifiers. */
+	int			outer_count;
+
+	/* Number of inner rel identifiers. */
+	int			inner_count;
+
+	/*
+	 * Trove lookup results.
+	 *
+	 * join_entries and rel_entries are arrays of entries, and join_indexes
+	 * and rel_indexes are the integer offsets within those arrays of entries
+	 * potentially relevant to us. The "join" fields correspond to a lookup
+	 * using PGPA_TROVE_LOOKUP_JOIN and the "rel" fields to a lookup using
+	 * PGPA_TROVE_LOOKUP_REL.
+	 */
+	pgpa_trove_entry *join_entries;
+	Bitmapset  *join_indexes;
+	pgpa_trove_entry *rel_entries;
+	Bitmapset  *rel_indexes;
+} pgpa_join_state;
+
+/* Saved hook values */
+static get_relation_info_hook_type prev_get_relation_info = NULL;
+static join_path_setup_hook_type prev_join_path_setup = NULL;
+static joinrel_setup_hook_type prev_joinrel_setup = NULL;
+static planner_setup_hook_type prev_planner_setup = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+
+/* Other global variabes */
+static int	planner_extension_id = -1;
+
+/* Function prototypes. */
+static void pgpa_get_relation_info(PlannerInfo *root,
+								   Oid relationObjectId,
+								   bool inhparent,
+								   RelOptInfo *rel);
+static void pgpa_joinrel_setup(PlannerInfo *root,
+							   RelOptInfo *joinrel,
+							   RelOptInfo *outerrel,
+							   RelOptInfo *innerrel,
+							   SpecialJoinInfo *sjinfo,
+							   List *restrictlist);
+static void pgpa_join_path_setup(PlannerInfo *root,
+								 RelOptInfo *joinrel,
+								 RelOptInfo *outerrel,
+								 RelOptInfo *innerrel,
+								 JoinType jointype,
+								 JoinPathExtraData *extra);
+static void pgpa_planner_setup(PlannerGlobal *glob, Query *parse,
+							   const char *query_string,
+							   double *tuple_fraction,
+							   ExplainState *es);
+static void pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string, PlannedStmt *pstmt);
+static void pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p,
+											  char *plan_name,
+											  pgpa_join_state *pjs);
+static void pgpa_planner_apply_join_path_advice(JoinType jointype,
+												uint64 *pgs_mask_p,
+												char *plan_name,
+												pgpa_join_state *pjs);
+static void pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+										   pgpa_trove_entry *scan_entries,
+										   Bitmapset *scan_indexes,
+										   pgpa_trove_entry *rel_entries,
+										   Bitmapset *rel_indexes);
+static uint64 pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag);
+static bool pgpa_join_order_permits_join(int outer_count, int inner_count,
+										 pgpa_identifier *rids,
+										 pgpa_trove_entry *entry);
+static bool pgpa_join_method_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+
+static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+										  pgpa_trove_lookup_type type,
+										  pgpa_identifier *rt_identifiers,
+										  pgpa_plan_walker_context *walker);
+
+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);
+
+/*
+ * Install planner-related hooks.
+ */
+void
+pgpa_planner_install_hooks(void)
+{
+	planner_extension_id = GetPlannerExtensionId("pg_plan_advice");
+	prev_get_relation_info = get_relation_info_hook;
+	get_relation_info_hook = pgpa_get_relation_info;
+	prev_joinrel_setup = joinrel_setup_hook;
+	joinrel_setup_hook = pgpa_joinrel_setup;
+	prev_join_path_setup = join_path_setup_hook;
+	join_path_setup_hook = pgpa_join_path_setup;
+	prev_planner_setup = planner_setup_hook;
+	planner_setup_hook = pgpa_planner_setup;
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgpa_planner_shutdown;
+}
+
+/*
+ * Hook function for get_relation_info().
+ *
+ * We can apply scan advice at this opint, and we also usee this as an
+ * opportunity to do range-table identifier cross-checking in assert-enabled
+ * builds.
+ *
+ * XXX: We currently emit useless advice like NO_GATHER("*RESULT*") for trivial
+ * queries. The advice is useless because get_relation_info isn't called for
+ * non-relation RTEs. We should either suppress the advice in such cases, or
+ * add a hook that can apply it.
+ */
+static void
+pgpa_get_relation_info(PlannerInfo *root, Oid relationObjectId,
+					   bool inhparent, RelOptInfo *rel)
+{
+	pgpa_planner_state *pps;
+
+	/* 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. */
+	if (pps != NULL)
+		pgpa_ri_checker_save(pps, root, rel);
+
+	/* If query advice was provided, search for relevant entries. */
+	if (pps != NULL && pps->trove != NULL)
+	{
+		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,
+						  &tresult_scan);
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+						  &tresult_rel);
+
+		/* If relevant entries were found, apply them. */
+		if (tresult_scan.indexes != NULL || tresult_rel.indexes != NULL)
+			pgpa_planner_apply_scan_advice(rel,
+										   tresult_scan.entries,
+										   tresult_scan.indexes,
+										   tresult_rel.entries,
+										   tresult_rel.indexes);
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_get_relation_info)
+		(*prev_get_relation_info) (root, relationObjectId, inhparent, rel);
+}
+
+/*
+ * Search for advice pertaining to a proposed join.
+ */
+static pgpa_join_state *
+pgpa_get_join_state(PlannerInfo *root, RelOptInfo *joinrel,
+					RelOptInfo *outerrel, RelOptInfo *innerrel)
+{
+	pgpa_planner_state *pps;
+	pgpa_join_state *pjs;
+	bool		new_pjs = false;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+	if (pps == NULL || pps->trove == NULL)
+	{
+		/* No advice applies to this query, hence none to this joinrel. */
+		return NULL;
+	}
+
+	/*
+	 * See whether we've previously associated a pgpa_join_state with this
+	 * joinrel. If we have not, we need to try to construct one. If we have,
+	 * then there are two cases: (a) if innerrel and outerrel are unchanged,
+	 * we can simply use it, and (b) if they have changed, we need to rejigger
+	 * the array of identifiers but can still skip the trove lookup.
+	 */
+	pjs = GetRelOptInfoExtensionState(joinrel, planner_extension_id);
+	if (pjs != NULL)
+	{
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+		{
+			/*
+			 * If there's no potentially relevant advice, then the presence of
+			 * this pgpa_join_state acts like a negative cache entry: it tells
+			 * us not to bother searching the trove for advice, because we
+			 * will not find any.
+			 */
+			return NULL;
+		}
+
+		if (pjs->outerrel == outerrel && pjs->innerrel == innerrel)
+		{
+			/* No updates required, so just return. */
+			/* XXX. Does this need to do something different under GEQO? */
+			return pjs;
+		}
+	}
+
+	/*
+	 * If there's no pgpa_join_state yet, we need to allocate one. Trove keys
+	 * will not get built for RTE_JOIN RTEs, so the array may end up being
+	 * larger than needed. It's not worth trying to compute a perfectly
+	 * accurate count here.
+	 */
+	if (pjs == NULL)
+	{
+		int			pessimistic_count = bms_num_members(joinrel->relids);
+
+		pjs = palloc0_object(pgpa_join_state);
+		pjs->rids = palloc_array(pgpa_identifier, pessimistic_count);
+		new_pjs = true;
+	}
+
+	/*
+	 * Either we just allocated a new pgpa_join_state, or the existing one
+	 * needs reconfiguring for a new innerrel and outerrel. The required array
+	 * size can't change, so we can overwrite the existing one.
+	 */
+	pjs->outerrel = outerrel;
+	pjs->innerrel = innerrel;
+	pjs->outer_count =
+		pgpa_compute_identifiers_by_relids(root, outerrel->relids, pjs->rids);
+	pjs->inner_count =
+		pgpa_compute_identifiers_by_relids(root, innerrel->relids,
+										   pjs->rids + pjs->outer_count);
+
+	/*
+	 * If we allocated a new pgpa_join_state, search our trove of advice for
+	 * relevant entries. The trove lookup will return the same results for
+	 * every outerrel/innerrel combination, so we don't need to repeat that
+	 * work every time.
+	 */
+	if (new_pjs)
+	{
+		pgpa_trove_result tresult;
+
+		/* Find join entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_JOIN,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->join_entries = tresult.entries;
+		pjs->join_indexes = tresult.indexes;
+
+		/* Find rel entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->rel_entries = tresult.entries;
+		pjs->rel_indexes = tresult.indexes;
+
+		/* Now that the new pgpa_join_state is fully valid, save a pointer. */
+		SetRelOptInfoExtensionState(joinrel, planner_extension_id, pjs);
+
+		/*
+		 * If there was no relevant advice found, just return NULL. This
+		 * pgpa_join_state will stick around as a sort of negative cache
+		 * entry, so that future calls for this same joinrel quickly return
+		 * NULL.
+		 */
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+			return NULL;
+	}
+
+	return pjs;
+}
+
+/*
+ * Enforce any provided advice that is relevant to any method of implementing
+ * this join.
+ *
+ * Although we're passed the outerrel and innerrel here, those are just
+ * whatever values happened to prompt the creation of this joinrel; they
+ * shouldn't really influence our choice of what advice to apply.
+ */
+static void
+pgpa_joinrel_setup(PlannerInfo *root, RelOptInfo *joinrel,
+				   RelOptInfo *outerrel, RelOptInfo *innerrel,
+				   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_joinrel_advice(&joinrel->pgs_mask,
+										  root->plan_name,
+										  pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_joinrel_setup)
+		(*prev_joinrel_setup) (root, joinrel, outerrel, innerrel,
+							   sjinfo, restrictlist);
+}
+
+/*
+ * Enforce any provided advice that is relevant to this particular method of
+ * implementing this particular join.
+ */
+static void
+pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
+					 RelOptInfo *outerrel, RelOptInfo *innerrel,
+					 JoinType jointype, JoinPathExtraData *extra)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_join_path_advice(jointype,
+											&extra->pgs_mask,
+											root->plan_name,
+											pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_join_path_setup)
+		(*prev_join_path_setup) (root, joinrel, outerrel, innerrel,
+								 jointype, extra);
+}
+
+/*
+ * Prepare advice for use by a query.
+ */
+static void
+pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
+				   double *tuple_fraction, ExplainState *es)
+{
+	pgpa_trove *trove = NULL;
+	pgpa_planner_state *pps;
+	bool		needs_pps = false;
+
+	/*
+	 * If any advice was provided, build a trove of advice for use during
+	 * planning.
+	 */
+	if (pg_plan_advice_advice != NULL && pg_plan_advice_advice[0] != '\0')
+	{
+		List	   *advice_items;
+		char	   *error;
+
+		/*
+		 * Parsing shouldn't fail here, because we must have previously parsed
+		 * successfully in pg_plan_advice_advice_check_hook, but if it does,
+		 * emit a warning.
+		 */
+		advice_items = pgpa_parse(pg_plan_advice_advice, &error);
+		if (error)
+			elog(WARNING, "could not parse advice: %s", error);
+
+		/*
+		 * It's possible that the advice string was non-empty but contained no
+		 * actual advice, e.g. it was all whitespace.
+		 */
+		if (advice_items != NIL)
+		{
+			trove = pgpa_build_trove(advice_items);
+			needs_pps = true;
+		}
+	}
+
+#ifdef USE_ASSERT_CHECKING
+
+	/*
+	 * If asserts are enabled, always build a private state object for
+	 * cross-checks.
+	 */
+	needs_pps = true;
+#endif
+
+	/* Initialize and store private state, if required. */
+	if (needs_pps)
+	{
+		pps = palloc0_object(pgpa_planner_state);
+		pps->explain_state = es;
+		pps->trove = trove;
+#ifdef USE_ASSERT_CHECKING
+		pps->ri_check_hash =
+			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
+#endif
+		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
+	}
+}
+
+/*
+ * Carry out whatever work we want to do after planning is complete.
+ */
+static void
+pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	pgpa_planner_state *pps;
+	pgpa_trove *trove = NULL;
+	ExplainState *es = NULL;
+	pgpa_plan_walker_context walker = {0};	/* placate compiler */
+	bool		do_advice_feedback;
+	bool		do_collect_advice;
+	List	   *pgpa_items = NIL;
+	pgpa_identifier *rt_identifiers = NULL;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
+	if (pps != NULL)
+	{
+		trove = pps->trove;
+		es = pps->explain_state;
+	}
+
+	/* If at least one collector is enabled, generate advice. */
+	do_collect_advice = (pg_plan_advice_local_collection_limit > 0 ||
+						 pg_plan_advice_shared_collection_limit > 0);
+
+	/* If we applied advice, generate feedback. */
+	do_advice_feedback = (trove != NULL && es != NULL);
+
+	/* If either of the above apply, analyze the resulting PlannedStmt. */
+	if (do_collect_advice || do_advice_feedback)
+	{
+		pgpa_plan_walker(&walker, pstmt);
+		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+	}
+
+	/*
+	 * If advice collection is enabled, put the advice in string form and send
+	 * it to the collector.
+	 */
+	if (do_collect_advice)
+	{
+		char	   *advice_string;
+		StringInfoData buf;
+
+		/* Generate a textual advice string. */
+		initStringInfo(&buf);
+		pgpa_output_advice(&buf, &walker, rt_identifiers);
+		advice_string = buf.data;
+
+		/* If the advice string is empty, don't bother collecting it. */
+		if (advice_string[0] != '\0')
+			pgpa_collect_advice(pstmt->queryId, query_string, advice_string);
+
+		/*
+		 * If we've gone to the trouble of generating an advice string, and if
+		 * we're inside EXPLAIN, save the string so we don't need to
+		 * regenerate it.
+		 */
+		if (es != NULL)
+			pgpa_items = lappend(pgpa_items,
+								 makeDefElem("advice_string",
+											 (Node *) makeString(advice_string),
+											 -1));
+	}
+
+	/*
+	 * If we are planning within EXPLAIN, make arrangements to allow EXPLAIN
+	 * to tell the user what has happened with the provided advice.
+	 *
+	 * NB: If EXPLAIN is used on a prepared is a prepared statement, planning
+	 * will have already happened happened without recording these details. We
+	 * could consider adding a GUC to cater to that scenario; or we could do
+	 * this work all the time, but that seems like too much overhead.
+	 */
+	if (do_advice_feedback)
+	{
+		List	   *feedback = NIL;
+
+		/*
+		 * Inject a Node-tree representation of all the trove-entry flags into
+		 * the PlannedStmt.
+		 */
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_SCAN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_JOIN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_REL,
+												rt_identifiers, &walker);
+
+		pgpa_items = lappend(pgpa_items, makeDefElem("feedback",
+													 (Node *) feedback,
+													 -1));
+	}
+
+	/* Push whatever data we're saving into the PlannedStmt. */
+	if (pgpa_items != NIL)
+		pstmt->extension_state =
+			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);
+}
+
+/*
+ * Enforce overall restrictions on a join relation that apply uniformly
+ * regardless of the choice of inner and outer rel.
+ */
+static void
+pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
+								  pgpa_join_state *pjs)
+{
+	int			i = -1;
+	int			flags;
+	bool		gather_conflict = false;
+	uint64		gather_mask = 0;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	bool		partitionwise_conflict = false;
+	int			partitionwise_outcome = 0;
+	Bitmapset  *partitionwise_partial_match = NULL;
+	Bitmapset  *partitionwise_full_match = NULL;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type itm;
+		bool		full_match = false;
+		uint64		my_gather_mask = 0;
+		int			my_partitionwise_outcome = 0;	/* >0 yes, <0 no */
+
+		/*
+		 * For GATHER and GATHER_MERGE, if the specified relations exactly
+		 * match this joinrel, do whatever the advice says; otherwise, don't
+		 * allow Gather or Gather Merge at this level. For NO_GATHER, there
+		 * must be a single target relation which must be included in this
+		 * joinrel, so just don't allow Gather or Gather Merge here, full
+		 * stop.
+		 */
+		if (entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			full_match = true;
+		}
+		else
+		{
+			int			total_count;
+
+			total_count = pjs->outer_count + pjs->inner_count;
+			itm = pgpa_identifiers_match_target(total_count, pjs->rids,
+												entry->target);
+			Assert(itm != PGPA_ITM_DISJOINT);
+
+			if (itm == PGPA_ITM_EQUAL)
+			{
+				full_match = true;
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+					my_partitionwise_outcome = 1;
+				else if (entry->tag == PGPA_TAG_GATHER)
+					my_gather_mask = PGS_GATHER;
+				else if (entry->tag == PGPA_TAG_GATHER_MERGE)
+					my_gather_mask = PGS_GATHER_MERGE;
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+			else
+			{
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else if (entry->tag == PGPA_TAG_GATHER ||
+						 entry->tag == PGPA_TAG_GATHER_MERGE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (full_match)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+
+		/*
+		 * Likewise, if we set my_partitionwise_outcome up above, then we (1)
+		 * make a note if the advice conflicted, (2) remember what the desired
+		 * outcome was, and (3) remember whether this was a full or partial
+		 * match.
+		 */
+		if (my_partitionwise_outcome != 0)
+		{
+			if (partitionwise_outcome != 0 &&
+				partitionwise_outcome != my_partitionwise_outcome)
+				partitionwise_conflict = true;
+			partitionwise_outcome = my_partitionwise_outcome;
+			if (full_match)
+				partitionwise_full_match =
+					bms_add_member(partitionwise_full_match, i);
+			else
+				partitionwise_partial_match =
+					bms_add_member(partitionwise_partial_match, i);
+		}
+	}
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched, and if
+	 * the set of targets exactly matched this relation, fully matched. If
+	 * there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
+
+	/* Likewise for partitionwise advice. */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (partitionwise_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		*pgs_mask_p &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		*pgs_mask_p |= gather_mask;
+	}
+
+	/*
+	 * If there is a non-conflicting partitionwise specification, enforce.
+	 *
+	 * To force a partitionwise join, we disable all the ordinary means of
+	 * performing a join, and instead only Append and MergeAppend paths here.
+	 * To prevent one, we just disable Append and MergeAppend.  Note that we
+	 * must not unset PGS_CONSIDER_PARTITIONWISE even when we don't want a
+	 * partitionwise join here, because we might want one at a higher level
+	 * that is constructing using paths from this level.
+	 */
+	if (partitionwise_outcome != 0 && !partitionwise_conflict)
+	{
+		if (partitionwise_outcome > 0)
+			*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) |
+				PGS_APPEND | PGS_MERGE_APPEND | PGS_CONSIDER_PARTITIONWISE;
+		else
+			*pgs_mask_p &= ~(PGS_APPEND | PGS_MERGE_APPEND);
+	}
+}
+
+/*
+ * Enforce restrictions on the join order or join method.
+ *
+ * Note that, although it is possible to view PARTITIONWISE advice as
+ * controlling the join method, we can't enforce it here, because the code
+ * path where this executes only deals with join paths that are built directly
+ * from a single outer path and a single inner path.
+ */
+static void
+pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
+									char *plan_name,
+									pgpa_join_state *pjs)
+{
+	int			i = -1;
+	Bitmapset  *jo_permit_indexes = NULL;
+	Bitmapset  *jo_deny_indexes = NULL;
+	Bitmapset  *jm_indexes = NULL;
+	bool		jm_conflict = false;
+	uint32		join_mask = 0;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->join_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->join_entries[i];
+		uint32		my_join_mask;
+
+		/* Handle join order advice. */
+		if (entry->tag == PGPA_TAG_JOIN_ORDER)
+		{
+			if (pgpa_join_order_permits_join(pjs->outer_count,
+											 pjs->inner_count,
+											 pjs->rids,
+											 entry))
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			else
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			continue;
+		}
+
+		/* Handle join strategy advice. */
+		my_join_mask = pgpa_join_strategy_mask_from_advice_tag(entry->tag);
+		if (my_join_mask != 0)
+		{
+			bool		permit;
+			bool		restrict_method;
+
+			if (entry->tag == PGPA_TAG_FOREIGN_JOIN)
+				permit = pgpa_opaque_join_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			else
+				permit = pgpa_join_method_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			if (!permit)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				jm_indexes = bms_add_member(jo_permit_indexes, i);
+				if (join_mask != 0 && join_mask != my_join_mask)
+					jm_conflict = true;
+				join_mask = my_join_mask;
+			}
+			continue;
+		}
+
+		/* Handle semijoin uniqueness advice. */
+		if (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE ||
+			entry->tag == PGPA_TAG_SEMIJOIN_NON_UNIQUE)
+		{
+			bool		advice_unique;
+			bool		jt_unique;
+			bool		jt_non_unique;
+			bool		restrict_method;
+
+			/* Advice wants to unique-ify and use a regular join? */
+			advice_unique = (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE);
+
+			/* Planner is trying to unique-ify and use a regular join? */
+			jt_unique = (jointype == JOIN_UNIQUE_INNER ||
+						 jointype == JOIN_UNIQUE_OUTER);
+
+			/* Planner is trying a semi-join, without unique-ifying? */
+			jt_non_unique = (jointype == JOIN_SEMI ||
+							 jointype == JOIN_RIGHT_SEMI);
+
+			/*
+			 * These advice tags behave very much like join method advice, in
+			 * that they want the inner side of the semijoin to match the
+			 * relations listed in the advice. Hence, we test whether join
+			 * method advice would enforce a join order restriction here, and
+			 * disallow the join if not.
+			 *
+			 * XXX. Think harder about right semijoins.
+			 */
+			if (!pgpa_join_method_permits_join(pjs->outer_count,
+											   pjs->inner_count,
+											   pjs->rids,
+											   entry,
+											   &restrict_method))
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				if (!jt_unique && !jt_non_unique)
+				{
+					/*
+					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
+					 * or SJ_NON_UNIQUE can be applied.
+					 */
+					entry->flags |= PGPA_TE_INAPPLICABLE;
+				}
+				else if (advice_unique != jt_unique)
+					jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			}
+			continue;
+		}
+	}
+
+	/*
+	 * If the advice indicates both that this join order is permissible and
+	 * also that it isn't, then mark advice related to the join order as
+	 * conflicting.
+	 */
+	if (jo_permit_indexes != NULL && jo_deny_indexes != NULL)
+	{
+		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * If more than one join method specification is relevant here and they
+	 * differ, mark them all as conflicting.
+	 */
+	if (jm_conflict)
+		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
+							 PGPA_TE_CONFLICTING);
+
+	/*
+	 * If we were advised to deny this join order, then do so. However, if we
+	 * were also advised to permit it, then do nothing, since the advice
+	 * conflicts.
+	 */
+	if (jo_deny_indexes != NULL && jo_permit_indexes == NULL)
+		*pgs_mask_p = 0;
+
+	/*
+	 * If we were advised to restrict the join method, then do so. However, if
+	 * we got conflicting join method advice or were also advised to reject
+	 * this join order completely, then instead do nothing.
+	 */
+	if (join_mask != 0 && !jm_conflict && jo_deny_indexes == NULL)
+		*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) | join_mask;
+}
+
+/*
+ * Translate an advice tag into a path generation strategy mask.
+ *
+ * This function can be called with tag types that don't represent join
+ * strategies. In such cases, we just return 0, which can't be confused with
+ * a valid mask.
+ */
+static uint64
+pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag)
+{
+	switch (tag)
+	{
+		case PGPA_TAG_FOREIGN_JOIN:
+			return PGS_FOREIGNJOIN;
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return PGS_MERGEJOIN_PLAIN;
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return PGS_MERGEJOIN_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return PGS_NESTLOOP_PLAIN;
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return PGS_NESTLOOP_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return PGS_NESTLOOP_MEMOIZE;
+		case PGPA_TAG_HASH_JOIN:
+			return PGS_HASHJOIN;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Does a certain item of join order advice permit a certain join?
+ */
+static bool
+pgpa_join_order_permits_join(int outer_count, int inner_count,
+							 pgpa_identifier *rids,
+							 pgpa_trove_entry *entry)
+{
+	bool		loop = true;
+	bool		sublist = false;
+	int			length;
+	int			outer_length;
+	pgpa_advice_target *target = entry->target;
+	pgpa_advice_target *prefix_target;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * Find the innermost sublist that contains all keys; if no sublist does,
+	 * then continue processing with the toplevel list.
+	 *
+	 * For example, if the advice says JOIN_ORDER(t1 t2 (t3 t4 t5)), then we
+	 * should evaluate joins that only involve t3, t4, and/or t5 against the
+	 * (t3 t4 t5) sublist, and others against the full list.
+	 *
+	 * Note that (1) outermost sublist is always ordered and (2) whenever we
+	 * zoom into an unordered sublist, we instantly accept the proposed join.
+	 * If the advice says JOIN_ORDER(t1 t2 {t3 t4 t5}), any approach to
+	 * joining t3, t4, and/or t5 is acceptable.
+	 */
+	while (loop)
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+		loop = false;
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_itm_type itm;
+
+			if (child_target->ttype == PGPA_TARGET_IDENTIFIER)
+				continue;
+
+			itm = pgpa_identifiers_match_target(outer_count + inner_count,
+												rids, child_target);
+			if (itm == PGPA_ITM_EQUAL || itm == PGPA_ITM_KEYS_ARE_SUBSET)
+			{
+				if (child_target->ttype == PGPA_TARGET_ORDERED_LIST)
+				{
+					target = child_target;
+					sublist = true;
+					loop = true;
+					break;
+				}
+				else
+				{
+					Assert(child_target->ttype == PGPA_TARGET_UNORDERED_LIST);
+					return true;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Try to find a prefix of the selected join order list that is exactly
+	 * equal to the outer side of the proposed join.
+	 */
+	length = list_length(target->children);
+	prefix_target = palloc0_object(pgpa_advice_target);
+	prefix_target->ttype = PGPA_TARGET_ORDERED_LIST;
+	for (outer_length = 1; outer_length <= length; ++outer_length)
+	{
+		pgpa_itm_type itm;
+
+		/* Avoid leaking memory in every loop iteration. */
+		if (prefix_target->children != NULL)
+			list_free(prefix_target->children);
+		prefix_target->children = list_copy_head(target->children,
+												 outer_length);
+
+		/* Search, hoping to find an exact match. */
+		itm = pgpa_identifiers_match_target(outer_count, rids, prefix_target);
+		if (itm == PGPA_ITM_EQUAL)
+			break;
+
+		/*
+		 * If the prefix of the join order list that we're considering
+		 * includes some but not all of the outer rels, we can make the prefix
+		 * longer to find an exact match. But the advice hasn't mentioned
+		 * everything that's part of our outer rel yet, but has mentioned
+		 * things that are not, then this join doesn't match the join order
+		 * list.
+		 */
+		if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+			return false;
+	}
+
+	/*
+	 * If the previous looped stopped before the prefix_target included the
+	 * entire join order list, then the next member of the join order list
+	 * must exactly match the inner side of the join.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), if the outer side of the
+	 * current join includes only t1, then the inner side must be exactly t2;
+	 * if the outer side includes both t1 and t2, then the inner side must
+	 * include exactly t3, t4, and t5.
+	 */
+	if (outer_length < length)
+	{
+		pgpa_advice_target *inner_target;
+		pgpa_itm_type itm;
+
+		inner_target = list_nth(target->children, outer_length);
+
+		itm = pgpa_identifiers_match_target(inner_count, rids + outer_count,
+											inner_target);
+
+		/*
+		 * Before returning, consider whether we need to mark this entry as
+		 * fully matched. If we found every item but one on the lefthand side
+		 * of the join and the last item on the righthand side of the join,
+		 * then the answer is yes.
+		 */
+		if (outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
+			entry->flags |= PGPA_TE_MATCH_FULL;
+
+		return (itm == PGPA_ITM_EQUAL);
+	}
+
+	/*
+	 * If we get here, then the outer side of the join includes the entirety
+	 * of the join order list. In this case, we behave differently depending
+	 * on whether we're looking at the top-level join order list or sublist.
+	 * At the top-level, we treat the specified list as mandating that the
+	 * actual join order has the given list as a prefix, but a sublist
+	 * requires an exact match.
+	 *
+	 * Exmaple: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), we must start by joining
+	 * all five of those relations and in that sequence, but once that is
+	 * done, it's OK to join any other rels that are part of the join problem.
+	 * This allows a user to specify the driving table and perhaps the first
+	 * few things to which it should be joined while leaving the rest of the
+	 * join order up the optimizer. But it seems like it would be surprising,
+	 * given that specification, if the user could add t6 to the (t3 t4 t5)
+	 * sub-join, so we don't allow that. If we did want to allow it, the logic
+	 * earlier in this function would require substantial adjustment: we could
+	 * allow the t3-t4-t5-t6 join to be built here, but the next step of
+	 * joining t1-t2 to the result would still be rejected.
+	 */
+	return !sublist;
+}
+
+/*
+ * Does a certain item of join method advice permit a certain join?
+ *
+ * Advice such as HASH_JOIN((x y)) means that there should be a hash join with
+ * exactly x and y on the inner side. Obviously, this means that if we are
+ * considering a join with exactly x and y on the inner side, we should enforce
+ * the use of a hash join. However, it also means that we must reject some
+ * incompatible join orders entirely.  For example, a join with exactly x
+ * and y on the outer side shouldn't be allowed, because such paths might win
+ * over the advice-driven path on cost.
+ *
+ * To accommodate these requirements, this function returns true if the join
+ * should be allowed and false if it should not. Furthermore, *restrict_method
+ * is set to true if the join method should be enforced and false if not.
+ */
+static bool
+pgpa_join_method_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	/*
+	 * If our inner rel mentions exactly the same relations as the advice
+	 * target, allow the join and enforce the join method restriction.
+	 *
+	 * If our inner rel mentions a superset of the target relations, allow the
+	 * join. The join we care about has already taken place, and this advice
+	 * imposes no further restrictions.
+	 */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If our outer rel mentions a supserset of the relations in the advice
+	 * target, no restrictions apply. The join we care has already taken
+	 * place, and this advice imposes no further restrictions.
+	 *
+	 * On the other hand, if our outer rel mentions exactly the relations
+	 * mentioned in the advice target, the planner is trying to reverse the
+	 * sides of the join as compared with our desired outcome. Reject that.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+	else if (outer_itm == PGPA_ITM_EQUAL)
+		return false;
+
+	/*
+	 * If the advice target mentions only a single relation, the test below
+	 * cannot ever pass, so save some work by exiting now.
+	 */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+		return false;
+
+	/*
+	 * If everything in the joinrel is appears in the advice target, we're
+	 * below the level of the join we want to control.
+	 *
+	 * For example, HASH_JOIN((x y)) doesn't restrict how x and y can be
+	 * joined.
+	 *
+	 * This lookup shouldn't return PGPA_ITM_DISJOINT, because any such advice
+	 * should not have been returned from the trove in the first place.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've already permitted all allowable cases, so reject this.
+	 *
+	 * If we reach this point, then the advice overlaps with this join but
+	 * isn't entirely contained within either side, and there's also at least
+	 * one relation present in the join that isn't mentioned by the advice.
+	 *
+	 * For instance, in the HASH_JOIN((x y)) example, we would reach here if x
+	 * were on one side of the join, y on the other, and at least one of the
+	 * two sides also included some other relation, say t. In that case,
+	 * accepting this join would allow the (x y t) joinrel to contain
+	 * non-disabled paths that do not put (x y) on the inner side of a hash
+	 * join; we could instead end up with something like (x JOIN t) JOIN y.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning an opaque join permit a certain join?
+ *
+ * By an opaque join, we mean one where the exact mechanism by which the
+ * join is performed is not visible to PostgreSQL. Currently this is the
+ * case only for foreign joins: FOREIGN_JOIN((x y z)) means that x, y, and
+ * z are joined on the remote side, but we know nothing about the join order
+ * or join methods used over there.
+ */
+static bool
+pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	if (join_itm == PGPA_ITM_EQUAL)
+	{
+		/*
+		 * We have an exact match, and should therefore allow the join and
+		 * enforce the use of the relevant opaque join method.
+		 */
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+	{
+		/*
+		 * If join_itm == PGPA_ITM_TARGETS_ARE_SUBSET, then the join we care
+		 * about has already taken place and no further restrictions apply.
+		 *
+		 * If join_itm == PGPA_ITM_KEYS_ARE_SUBSET, we're still building up to
+		 * the join we care about and have not introduced any extraneous
+		 * relations not named in the advice. Note that ForeignScan paths for
+		 * joins are built up from ForeignScan paths from underlying joins and
+		 * scans, so we must not disable this join when considering a subset
+		 * of the relations we ultimately want.
+		 */
+		return true;
+	}
+
+	/*
+	 * The advice overlaps the join, but at least one relation is present in
+	 * the join that isn't mentioned by the advice. We want to disable such
+	 * paths so that we actually push down the join as intended.
+	 */
+	return false;
+}
+
+/*
+ * Apply scan advice to a RelOptInfo.
+ *
+ * XXX. For bitmap heap scans, we're just ignoring the index information from
+ * the advice. That's not cool.
+ */
+static void
+pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+							   pgpa_trove_entry *scan_entries,
+							   Bitmapset *scan_indexes,
+							   pgpa_trove_entry *rel_entries,
+							   Bitmapset *rel_indexes)
+{
+	bool		gather_conflict = false;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	int			i = -1;
+	pgpa_trove_entry *scan_entry = NULL;
+	int			flags;
+	bool		scan_type_conflict = false;
+	Bitmapset  *scan_type_indexes = NULL;
+	Bitmapset  *scan_type_rel_indexes = NULL;
+	uint64		gather_mask = 0;
+	uint64		scan_type = 0;
+
+	/* 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;
+
+		/* Translate our advice tags to a scan strategy advice value. */
+		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+			my_scan_type = PGS_BITMAPSCAN;
+		else if (my_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN)
+			my_scan_type = PGS_INDEXONLYSCAN | PGS_CONSIDER_INDEXONLY;
+		else if (my_entry->tag == PGPA_TAG_INDEX_SCAN)
+			my_scan_type = PGS_INDEXSCAN;
+		else if (my_entry->tag == PGPA_TAG_SEQ_SCAN)
+			my_scan_type = PGS_SEQSCAN;
+		else if (my_entry->tag == PGPA_TAG_TID_SCAN)
+			my_scan_type = PGS_TIDSCAN;
+
+		/*
+		 * If this is understandable scan advice, hang on to the entry, the
+		 * inferred scan type type, and the index at which we found it.
+		 *
+		 * Also make a note if we see conflicting scan type advice. Note that
+		 * we regard two index specifications as conflicting unless they match
+		 * exactly. In theory, perhaps we could regard INDEX_SCAN(a c) and
+		 * 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 (scan_type != 0 && scan_type != my_scan_type)
+				scan_type_conflict = true;
+			if (!scan_type_conflict && scan_entry != NULL &&
+				my_entry->target->itarget != NULL &&
+				scan_entry->target->itarget != NULL &&
+				!pgpa_index_targets_equal(scan_entry->target->itarget,
+										  my_entry->target->itarget))
+				scan_type_conflict = true;
+			scan_entry = my_entry;
+			scan_type = my_scan_type;
+			scan_type_indexes = bms_add_member(scan_type_indexes, i);
+		}
+	}
+
+	/* Scrutinize available gather-related and partitionwise advice. */
+	i = -1;
+	while ((i = bms_next_member(rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *my_entry = &rel_entries[i];
+		uint64		my_gather_mask = 0;
+		bool		just_one_rel;
+
+		just_one_rel = my_entry->target->ttype == PGPA_TARGET_IDENTIFIER
+			|| list_length(my_entry->target->children) == 1;
+
+		/*
+		 * PARTITIONWISE behaves like a scan type, except that if there's more
+		 * than one relation targeted, it has no effect at this level.
+		 */
+		if (my_entry->tag == PGPA_TAG_PARTITIONWISE)
+		{
+			if (just_one_rel)
+			{
+				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
+
+				if (scan_type != 0 && scan_type != my_scan_type)
+					scan_type_conflict = true;
+				scan_entry = my_entry;
+				scan_type = my_scan_type;
+				scan_type_rel_indexes =
+					bms_add_member(scan_type_rel_indexes, i);
+			}
+			continue;
+		}
+
+		/*
+		 * GATHER and GATHER_MERGE applied to a single rel mean that we should
+		 * use the correspondings strategy here, while applying either to more
+		 * than one rel means we should not use those strategies here, but
+		 * rather at the level of the joinrel that corresponds to what was
+		 * specified. NO_GATHER can only be applied to single rels.
+		 *
+		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
+		 * equivalent to allowing the non-use of either form of Gather here.
+		 */
+		if (my_entry->tag == PGPA_TAG_GATHER ||
+			my_entry->tag == PGPA_TAG_GATHER_MERGE)
+		{
+			if (!just_one_rel)
+				my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			else if (my_entry->tag == PGPA_TAG_GATHER)
+				my_gather_mask = PGS_GATHER;
+			else
+				my_gather_mask = PGS_GATHER_MERGE;
+		}
+		else if (my_entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			Assert(just_one_rel);
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (just_one_rel)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+	}
+
+	/* Enforce choice of index. */
+	if (scan_entry != NULL && !scan_type_conflict &&
+		(scan_entry->tag == PGPA_TAG_INDEX_SCAN ||
+		 scan_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN))
+	{
+		pgpa_index_target *itarget = scan_entry->target->itarget;
+		IndexOptInfo *matched_index = NULL;
+
+		Assert(itarget->itype == PGPA_INDEX_NAME);
+
+		foreach_node(IndexOptInfo, index, rel->indexlist)
+		{
+			char	   *relname = get_rel_name(index->indexoid);
+			Oid			nspoid = get_rel_namespace(index->indexoid);
+			char	   *relnamespace = get_namespace_name(nspoid);
+
+			if (strcmp(itarget->indname, relname) == 0 &&
+				(itarget->indnamespace == NULL ||
+				 strcmp(itarget->indnamespace, relnamespace) == 0))
+			{
+				matched_index = index;
+				break;
+			}
+		}
+
+		if (matched_index == NULL)
+		{
+			/* Don't force the scan type if the index doesn't exist. */
+			scan_type = 0;
+
+			/* Mark advice as inapplicable. */
+			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
+								 PGPA_TE_INAPPLICABLE);
+		}
+		else
+		{
+			/* Retain this index and discard the rest. */
+			rel->indexlist = list_make1(matched_index);
+		}
+	}
+
+	/*
+	 * Mark all the scan method entries as fully matched; and if they specify
+	 * different things, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	if (scan_type_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
+	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched. Mark
+	 * the ones that included this relation as a target by itself as fully
+	 * matched. If there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
+
+	/* If there is a non-conflicting scan specification, enforce it. */
+	if (scan_type != 0 && !scan_type_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
+			  PGS_CONSIDER_INDEXONLY);
+		rel->pgs_mask |= scan_type;
+	}
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		rel->pgs_mask |= gather_mask;
+	}
+}
+
+/*
+ * Add feedback entries to for one trove slice to the provided list and
+ * return the resulting list.
+ *
+ * Feedback entries are generated from the trove entry's flags. It's assumed
+ * that the caller has already set all relevant flags with the exception of
+ * PGPA_TE_FAILED. We set that flag here if appropriate.
+ */
+static List *
+pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+							 pgpa_trove_lookup_type type,
+							 pgpa_identifier *rt_identifiers,
+							 pgpa_plan_walker_context *walker)
+{
+	pgpa_trove_entry *entries;
+	int			nentries;
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	pgpa_trove_lookup_all(trove, type, &entries, &nentries);
+	for (int i = 0; i < nentries; ++i)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+		DefElem    *item;
+
+		/*
+		 * If this entry was fully matched, check whether generating advice
+		 * from this plan would produce such an entry. If not, label the entry
+		 * as failed.
+		 */
+		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+			!pgpa_walker_would_advise(walker, rt_identifiers,
+									  entry->tag, entry->target))
+			entry->flags |= PGPA_TE_FAILED;
+
+		item = makeDefElem(pgpa_cstring_trove_entry(entry),
+						   (Node *) makeInteger(entry->flags), -1);
+		list = lappend(list, item);
+	}
+
+	return list;
+}
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * Fast hash function for a key consisting of an RTI and plan name.
+ */
+static uint32
+pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	hs.accum = key.rti;
+	fasthash_combine(&hs);
+
+	/* plan_name can be NULL */
+	if (key.plan_name == NULL)
+		sp_len = 0;
+	else
+		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+
+	/* hashfn_unstable.h recommends using string length as tweak */
+	return fasthash_final32(&hs, sp_len);
+}
+
+#endif
+
+/*
+ * Save the range table identifier for one relation for future cross-checking.
+ */
+static void
+pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
+					 RelOptInfo *rel)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_checker_key key;
+	pgpa_ri_checker *check;
+	pgpa_identifier rid;
+	const char *rid_string;
+	bool		found;
+
+	key.rti = bms_singleton_member(rel->relids);
+	key.plan_name = root->plan_name;
+	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
+	rid_string = pgpa_identifier_string(&rid);
+	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
+	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
+	check->rid_string = rid_string;
+#endif
+}
+
+/*
+ * 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_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	pgpa_ri_check_iterator it;
+	pgpa_ri_checker *check;
+
+	/* 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. */
+	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
+	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	{
+		int			rtoffset = 0;
+		const char *rid_string;
+		Index		flat_rti;
+
+		/*
+		 * 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.
+		 */
+		if (check->key.plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				/*
+				 * If rtinfo->dummy is set, then the subquery's range table
+				 * will only have been partially copied to the final range
+				 * table. Specifically, only RTE_RELATION entries and
+				 * 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
+				 * in the final rtable.
+				 *
+				 * With more complex logic, we could work around that problem
+				 * by remembering the whole contents of the subquery's rtable
+				 * during planning, determining which of those would have been
+				 * copied to the final rtable, and matching them up. But it
+				 * doesn't seem like a worthwhile endeavor for right now,
+				 * because RTIs from such subqueries won't appear in the plan
+				 * tree itself, just in the range table. Hence, we can neither
+				 * generate nor accept advice for them.
+				 */
+				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+					&& !rtinfo->dummy)
+				{
+					rtoffset = rtinfo->rtoffset;
+					Assert(rtoffset > 0);
+					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;
+		}
+
+		/*
+		 * check->key.rti is the RTI that we saw prior to range-table
+		 * flattening, so we must add the appropriate RT offset to get the
+		 * final RTI.
+		 */
+		flat_rti = check->key.rti + rtoffset;
+		Assert(flat_rti <= list_length(pstmt->rtable));
+
+		/* Assert that the string we compute now matches the previous one. */
+		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
+		Assert(strcmp(rid_string, check->rid_string) == 0);
+	}
+#endif
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
new file mode 100644
index 00000000000..7d40b910b00
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -0,0 +1,17 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.h
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_PLANNER_H
+#define PGPA_PLANNER_H
+
+extern void pgpa_planner_install_hooks(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
new file mode 100644
index 00000000000..b351d3dbf92
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -0,0 +1,258 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.c
+ *	  analysis of scans in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+
+static pgpa_scan *pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								 pgpa_scan_strategy strategy,
+								 Bitmapset *relids,
+								 bool beneath_any_gather);
+
+
+static RTEKind unique_nonjoin_rtekind(Bitmapset *relids, List *rtable);
+
+/*
+ * Build a pgpa_scan object for a Plan node and update the plan walker
+ * context as appopriate.  If this is an Append or MergeAppend scan, also
+ * build pgpa_scan for any scans that were consolidated into this one by
+ * Append/MergeAppend pull-up.
+ *
+ * If there is at least one ElidedNode for this plan node, pass the uppermost
+ * one as elided_node, else pass NULL.
+ *
+ * Set the 'beneath_any_gather' node if we are underneath a Gather or
+ * Gather Merge node.
+ *
+ * Set the 'within_join_problem' flag if we're inside of a join problem and
+ * not otherwise.
+ */
+pgpa_scan *
+pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+				ElidedNode *elided_node,
+				bool beneath_any_gather, bool within_join_problem)
+{
+	pgpa_scan_strategy strategy = PGPA_SCAN_ORDINARY;
+	Bitmapset  *relids = NULL;
+	int			rti = -1;
+	List	   *child_append_relid_sets = NIL;
+
+	if (elided_node != NULL)
+	{
+		NodeTag		elided_type = elided_node->elided_type;
+
+		/*
+		 * If setrefs processing elided an Append or MergeAppend node that had
+		 * only one surviving child, then this is a partitionwise "scan" --
+		 * which may really be a partitionwise join, but there's no need to
+		 * distinguish.
+		 *
+		 * If it's a trivial SubqueryScan that was elided, then this is an
+		 * "ordinary" scan i.e. one for which we need to generate advice
+		 * because the planner has not made any meaningful choice.
+		 */
+		relids = elided_node->relids;
+		if (elided_type == T_Append || elided_type == T_MergeAppend)
+			strategy = PGPA_SCAN_PARTITIONWISE;
+		else
+			strategy = PGPA_SCAN_ORDINARY;
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+	{
+		relids = bms_make_singleton(rti);
+
+		switch (nodeTag(plan))
+		{
+			case T_SeqScan:
+				strategy = PGPA_SCAN_SEQ;
+				break;
+			case T_BitmapHeapScan:
+				strategy = PGPA_SCAN_BITMAP_HEAP;
+				break;
+			case T_IndexScan:
+				strategy = PGPA_SCAN_INDEX;
+				break;
+			case T_IndexOnlyScan:
+				strategy = PGPA_SCAN_INDEX_ONLY;
+				break;
+			case T_TidScan:
+			case T_TidRangeScan:
+				strategy = PGPA_SCAN_TID;
+				break;
+			default:
+
+				/*
+				 * This case includes a ForeignScan targeting a single
+				 * relation; no other strategy is possible in that case, but
+				 * see below, where things are different in multi-relation
+				 * cases.
+				 */
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+	}
+	else if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		switch (nodeTag(plan))
+		{
+			case T_ForeignScan:
+
+				/*
+				 * If multiple relations are being targeted by a single
+				 * foreign scan, then the foreign join has been pushed to the
+				 * remote side, and we want that to be reflected in the
+				 * generated advice.
+				 */
+				strategy = PGPA_SCAN_FOREIGN;
+				break;
+			case T_Append:
+
+				/*
+				 * Append nodes can represent partitionwise scans of a a
+				 * relation, but when they implement a set operation, they are
+				 * just ordinary scans.
+				 */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((Append *) plan)->child_append_relid_sets;
+				break;
+			case T_MergeAppend:
+				/* Some logic here as for Append, above. */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((MergeAppend *) plan)->child_append_relid_sets;
+				break;
+			default:
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+
+	/*
+	 * If this is an Append or MergeAppend node into which subordinate Append
+	 * or MergeAppend paths were merged, each of those merged paths is
+	 * effectively another scan for which we need to account.
+	 */
+	foreach_node(Bitmapset, child_relids, child_append_relid_sets)
+	{
+		Bitmapset  *child_nonjoin_relids;
+
+		child_nonjoin_relids =
+			pgpa_filter_out_join_relids(child_relids,
+										walker->pstmt->rtable);
+		(void) pgpa_make_scan(walker, plan, strategy,
+							  child_nonjoin_relids,
+							  beneath_any_gather);
+	}
+
+	/*
+	 * If this plan node has no associated RTIs, it's not a scan. When the
+	 * 'within_join_problem' flag is set, that's unexpected, so throw an
+	 * error, else return quietly.
+	 */
+	if (relids == NULL)
+	{
+		if (within_join_problem)
+			elog(ERROR, "plan node has no RTIs: %d", (int) nodeTag(plan));
+		return NULL;
+	}
+
+	return pgpa_make_scan(walker, plan, strategy, relids, beneath_any_gather);
+}
+
+/*
+ * Create a single pgpa_scan object and update the pgpa_plan_walker_context.
+ */
+static pgpa_scan *
+pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+			   pgpa_scan_strategy strategy, Bitmapset *relids,
+			   bool beneath_any_gather)
+{
+	pgpa_scan  *scan;
+
+	/* Create the scan object. */
+	scan = palloc(sizeof(pgpa_scan));
+	scan->plan = plan;
+	scan->strategy = strategy;
+	scan->relids = relids;
+	scan->beneath_any_gather = beneath_any_gather;
+
+	/* Add it to the appropriate list. */
+	walker->scans[scan->strategy] = lappend(walker->scans[scan->strategy],
+											scan);
+
+	/*
+	 * We intend to emit NO_GATHER() advice for each scan that doesn't appear
+	 * beneath a Gather or Gather Merge node, but we need not do this for
+	 * partitionwise scans, because emitting NO_GATHER() for the child scans
+	 * suffices.
+	 */
+	if (!scan->beneath_any_gather && scan->strategy != PGPA_SCAN_PARTITIONWISE)
+		walker->no_gather_scans = bms_add_members(walker->no_gather_scans,
+												  scan->relids);
+
+	return scan;
+}
+
+/*
+ * Determine the unique rtekind of a set of relids.
+ */
+static RTEKind
+unique_nonjoin_rtekind(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	bool		first = true;
+	RTEKind		rtekind;
+
+	Assert(relids != NULL);
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		if (first)
+		{
+			rtekind = rte->rtekind;
+			first = false;
+		}
+		else if (rtekind != rte->rtekind)
+			elog(ERROR, "rtekind mismatch: %d vs. %d",
+				 rtekind, rte->rtekind);
+	}
+
+	if (first)
+		elog(ERROR, "no non-RTE_JOIN RTEs found");
+
+	return rtekind;
+}
diff --git a/contrib/pg_plan_advice/pgpa_scan.h b/contrib/pg_plan_advice/pgpa_scan.h
new file mode 100644
index 00000000000..90a08b41c5b
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.h
@@ -0,0 +1,86 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.h
+ *	  analysis of scans in Plan trees
+ *
+ * For purposes of this module, a "scan" includes (1) single plan nodes that
+ * scan multiple RTIs, such as a degenerate Result node that replaces what
+ * would otherwise have been a join, and (2) Append and MergeAppend nodes
+ * implementing a partitionwise scan or a partitionwise join. Said
+ * differently, scans are the leaves of the join tree for a single join
+ * problem.
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_SCAN_H
+#define PGPA_SCAN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+
+/*
+ * Scan strategies.
+ *
+ * PGPA_SCAN_ORDINARY is any scan strategy that isn't interesting to us
+ * because there is no meaningful planner decision involved. For example,
+ * the only way to scan a subquery is a SubqueryScan, and the only way to
+ * scan a VALUES construct is a ValuesScan. We need not care exactly which
+ * type of planner node was used in such cases, because the same thing will
+ * happen when replanning.
+ *
+ * PGPA_SCAN_ORDINARY also includes Result nodes that correspond to scans
+ * or even joins that are proved empty. We don't know whether or not the scan
+ * or join will still be provably empty at replanning time, but if it is,
+ * then no scan-type advice is needed, and if it's not, we can't recommend
+ * a scan type based on the current plan.
+ *
+ * PGPA_SCAN_PARTITIONWISE also lumps together scans and joins: this can
+ * be either a partitionwise scan of a partitioned table or a partitionwise
+ * join between several partitioned tables. Note that all decisions about
+ * whether or not to use partitionwise join are meaningful: no matter what
+ * we decided this time, we could do more or fewer things partitionwise the
+ * next time.
+ *
+ * PGPA_SCAN_FOREIGN is only used when there's more than one relation involved;
+ * a single-table foreign scan is classified as ordinary, since there is no
+ * decision to make in that case.
+ *
+ * Other scan strategies map one-to-one to plan nodes.
+ */
+typedef enum
+{
+	PGPA_SCAN_ORDINARY = 0,
+	PGPA_SCAN_SEQ,
+	PGPA_SCAN_BITMAP_HEAP,
+	PGPA_SCAN_FOREIGN,
+	PGPA_SCAN_INDEX,
+	PGPA_SCAN_INDEX_ONLY,
+	PGPA_SCAN_PARTITIONWISE,
+	PGPA_SCAN_TID
+	/* update NUM_PGPA_SCAN_STRATEGY if you add anything here */
+} pgpa_scan_strategy;
+
+#define NUM_PGPA_SCAN_STRATEGY	((int) PGPA_SCAN_TID + 1)
+
+/*
+ * All of the details we need regarding a scan.
+ */
+typedef struct pgpa_scan
+{
+	Plan	   *plan;
+	pgpa_scan_strategy strategy;
+	Bitmapset  *relids;
+	bool		beneath_any_gather;
+} pgpa_scan;
+
+extern pgpa_scan *pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								  ElidedNode *elided_node,
+								  bool beneath_any_gather,
+								  bool within_join_problem);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scanner.l b/contrib/pg_plan_advice/pgpa_scanner.l
new file mode 100644
index 00000000000..be7d7ba13a6
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scanner.l
@@ -0,0 +1,299 @@
+%top{
+/*
+ * Scanner for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_scanner.l
+ */
+#include "postgres.h"
+
+#include "common/string.h"
+#include "nodes/miscnodes.h"
+#include "parser/scansup.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Extra data that we pass around when during scanning.
+ *
+ * 'litbuf' is used to implement the <xd> exclusive state, which handles
+ * double-quoted identifiers.
+ */
+typedef struct pgpa_yy_extra_type
+{
+	StringInfoData	litbuf;
+} pgpa_yy_extra_type;
+
+}
+
+%{
+/* LCOV_EXCL_START */
+
+#define YY_DECL \
+	extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result, \
+						  char **parse_error_msg_p, yyscan_t yyscanner)
+
+/* No reason to constrain amount of data slurped */
+#define YY_READ_BUF_SIZE 16777216
+
+/* Avoid exit() on fatal scanner errors (a bit ugly -- see yy_fatal_error) */
+#undef fprintf
+#define fprintf(file, fmt, msg)  fprintf_to_ereport(fmt, msg)
+
+static void
+fprintf_to_ereport(const char *fmt, const char *msg)
+{
+	ereport(ERROR, (errmsg_internal("%s", msg)));
+}
+%}
+
+%option reentrant
+%option bison-bridge
+%option 8bit
+%option never-interactive
+%option nodefault
+%option noinput
+%option nounput
+%option noyywrap
+%option noyyalloc
+%option noyyrealloc
+%option noyyfree
+%option warn
+%option prefix="pgpa_yy"
+%option extra-type="pgpa_yy_extra_type *"
+
+/*
+ * What follows is a severely stripped-down version of the core scanner. We
+ * only care about recognizing identifiers with or without identifier quoting
+ * (i.e. double-quoting), decimal integers, and a small handful of other
+ * things. Keep these rules in sync with src/backend/parser/scan.l. As in that
+ * file, we use an exclusive state called 'xc' for C-style comments, and an
+ * exclusive state called 'xd' for double-quoted identifiers.
+ */
+%x xc
+%x xd
+
+ident_start		[A-Za-z\200-\377_]
+ident_cont		[A-Za-z\200-\377_0-9\$]
+
+identifier		{ident_start}{ident_cont}*
+
+decdigit		[0-9]
+decinteger		{decdigit}(_?{decdigit})*
+
+space			[ \t\n\r\f\v]
+whitespace		{space}+
+
+dquote			\"
+xdstart			{dquote}
+xdstop			{dquote}
+xddouble		{dquote}{dquote}
+xdinside		[^"]+
+
+xcstart			\/\*
+xcstop			\*+\/
+xcinside		[^*/]+
+
+%%
+
+{whitespace}	{ /* ignore */ }
+
+{identifier}	{
+					char   *str;
+					bool	fail;
+					pgpa_advice_tag_type	tag;
+
+					/*
+					 * Unlike the core scanner, we don't truncate identifiers
+					 * here. There is no obvious reason to do so.
+					 */
+					str = downcase_identifier(yytext, yyleng, false, false);
+					yylval->str = str;
+
+					/*
+					 * If it's not a tag, just return TOK_IDENT; else, return
+					 * a token type based on how further parsing should
+					 * proceed.
+					 */
+					tag = pgpa_parse_advice_tag(str, &fail);
+					if (fail)
+						return TOK_IDENT;
+					else if (tag == PGPA_TAG_JOIN_ORDER)
+						return TOK_TAG_JOIN_ORDER;
+					else if (tag == PGPA_TAG_INDEX_SCAN ||
+							 tag == PGPA_TAG_INDEX_ONLY_SCAN)
+						return TOK_TAG_INDEX;
+					else if (tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+						return TOK_TAG_BITMAP;
+					else if (tag == PGPA_TAG_SEQ_SCAN ||
+							 tag == PGPA_TAG_TID_SCAN ||
+							 tag == PGPA_TAG_NO_GATHER)
+						return TOK_TAG_SIMPLE;
+					else
+						return TOK_TAG_GENERIC;
+				}
+
+{decinteger}	{
+					char   *endptr;
+
+					errno = 0;
+					yylval->integer = strtoint(yytext, &endptr, 10);
+					if (*endptr != '\0' || errno == ERANGE)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "integer out of range");
+					return TOK_INTEGER;
+				}
+
+{xcstart}		{
+					BEGIN(xc);
+				}
+
+{xdstart}		{
+					BEGIN(xd);
+					resetStringInfo(&yyextra->litbuf);
+				}
+
+"||"			{ return TOK_OR; }
+
+"&&"			{ return TOK_AND; }
+
+.				{ return yytext[0]; }
+
+<xc>{xcstop}	{
+					BEGIN(INITIAL);
+				}
+
+<xc>{xcinside}	{
+					/* discard multiple characters without slash or asterisk */
+				}
+
+<xc>.			{
+					/*
+					 * Discard any single character. flex prefers longer
+					 * matches, so this rule will never be picked when we could
+					 * have matched xcstop.
+					 *
+					 * NB: At present, we don't bother to support nested
+					 * C-style comments here, but this logic could be extended
+					 * if that restriction poses a problem.
+					 */
+				}
+
+<xc><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated comment");
+				}
+
+<xd>{xdstop}	{
+					BEGIN(INITIAL);
+					yylval->str = pstrdup(yyextra->litbuf.data);
+					return TOK_IDENT;
+				}
+
+<xd>{xddouble}	{
+					appendStringInfoChar(&yyextra->litbuf, '"');
+				}
+
+<xd>{xdinside}	{
+					appendBinaryStringInfo(&yyextra->litbuf, yytext, yyleng);
+				}
+
+<xd><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated quoted identifier");
+				}
+
+%%
+
+/* LCOV_EXCL_STOP */
+
+/*
+ * Handler for errors while scanning or parsing advice.
+ *
+ * bison passes the error message to us via 'message', and the context is
+ * available via the 'yytext' macro. We assemble those values into a final
+ * error text and then arrange to pass it back to the caller of pgpa_yyparse()
+ * by storing it into *parse_error_msg_p.
+ */
+void
+pgpa_yyerror(List **result, char **parse_error_msg_p, yyscan_t yyscanner,
+			 const char *message)
+{
+	struct yyguts_t *yyg = (struct yyguts_t *) yyscanner;	/* needed for yytext
+															 * macro */
+
+
+	/* report only the first error in a parse operation */
+	if (*parse_error_msg_p)
+		return;
+
+	if (yytext[0])
+		*parse_error_msg_p = psprintf("%s at or near \"%s\"", message, yytext);
+	else
+		*parse_error_msg_p = psprintf("%s at end of input", message);
+}
+
+/*
+ * Initialize the advice scanner.
+ *
+ * This should be called before parsing begins.
+ */
+void
+pgpa_scanner_init(const char *str, yyscan_t *yyscannerp)
+{
+	yyscan_t	yyscanner;
+	pgpa_yy_extra_type	*yyext = palloc0_object(pgpa_yy_extra_type);
+
+	if (yylex_init(yyscannerp) != 0)
+		elog(ERROR, "yylex_init() failed: %m");
+
+	yyscanner = *yyscannerp;
+
+	initStringInfo(&yyext->litbuf);
+	pgpa_yyset_extra(yyext, yyscanner);
+
+	yy_scan_string(str, yyscanner);
+}
+
+
+/*
+ * Shut down the advice scanner.
+ *
+ * This should be called after parsing is complete.
+ */
+void
+pgpa_scanner_finish(yyscan_t yyscanner)
+{
+	yylex_destroy(yyscanner);
+}
+
+/*
+ * Interface functions to make flex use palloc() instead of malloc().
+ * It'd be better to make these static, but flex insists otherwise.
+ */
+
+void *
+yyalloc(yy_size_t size, yyscan_t yyscanner)
+{
+	return palloc(size);
+}
+
+void *
+yyrealloc(void *ptr, yy_size_t size, yyscan_t yyscanner)
+{
+	if (ptr)
+		return repalloc(ptr, size);
+	else
+		return palloc(size);
+}
+
+void
+yyfree(void *ptr, yyscan_t yyscanner)
+{
+	if (ptr)
+		pfree(ptr);
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
new file mode 100644
index 00000000000..a92121feb1d
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -0,0 +1,490 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.c
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * This name comes from the English expression "trove of advice", which
+ * means a collection of wisdom. This slightly unusual term is chosen to
+ * avoid naming confusion; for example, "collection of advice" would
+ * invite confusion with pgpa_collector.c. Note that, while we don't know
+ * whether the provided advice is actually wise, it's not our job to
+ * question the user's choices.
+ *
+ * The goal of this module is to make it easy to locate the specific
+ * bits of advice that pertain to any given part of a query, or to
+ * determine that there are none.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_trove.h"
+
+#include "common/hashfn_unstable.h"
+
+/*
+ * An advice trove is organized into a series of "slices", each of which
+ * contains information about one topic e.g. scan methods. Each slice consists
+ * of an array of trove entries plus a hash table that we can use to determine
+ * which ones are relevant to a particular part of the query.
+ */
+typedef struct pgpa_trove_slice
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	pgpa_trove_entry *entries;
+	struct pgpa_trove_entry_hash *hash;
+} pgpa_trove_slice;
+
+/*
+ * Scan advice is stored into 'scan'; join advice is stored into 'join'; and
+ * advice that can apply to both cases is stored into 'rel'. This lets callers
+ * ask just for what's relevant. These slices correspond to the possible values
+ * of pgpa_trove_lookup_type.
+ */
+struct pgpa_trove
+{
+	pgpa_trove_slice join;
+	pgpa_trove_slice rel;
+	pgpa_trove_slice scan;
+};
+
+/*
+ * We're going to build a hash table to allow clients of this module to find
+ * relevant advice for a given part of the query quickly. However, we're going
+ * to use only three of the five key fields as hash keys. There are two reasons
+ * for this.
+ *
+ * First, it's allowable to set partition_schema to NULL to match a partition
+ * with the correct name in any schema.
+ *
+ * Second, we expect the "occurrence" and "partition_schema" portions of the
+ * relation identifiers to be mostly uninteresting. Most of the time, the
+ * occurrence field will be 1 and the partition_schema values will all be the
+ * same. Even when there is some variation, the absolute number of entries
+ * that have the same values for all three of these key fields should be
+ * quite small.
+ */
+typedef struct
+{
+	const char *alias_name;
+	const char *partition_name;
+	const char *plan_name;
+} pgpa_trove_entry_key;
+
+typedef struct
+{
+	pgpa_trove_entry_key key;
+	int			status;
+	Bitmapset  *indexes;
+} pgpa_trove_entry_element;
+
+static uint32 pgpa_trove_entry_hash_key(pgpa_trove_entry_key key);
+
+static inline bool
+pgpa_trove_entry_compare_key(pgpa_trove_entry_key a, pgpa_trove_entry_key b)
+{
+	if (strcmp(a.alias_name, b.alias_name) != 0)
+		return false;
+
+	if (!strings_equal_or_both_null(a.partition_name, b.partition_name))
+		return false;
+
+	if (!strings_equal_or_both_null(a.plan_name, b.plan_name))
+		return false;
+
+	return true;
+}
+
+#define SH_PREFIX			pgpa_trove_entry
+#define SH_ELEMENT_TYPE		pgpa_trove_entry_element
+#define SH_KEY_TYPE			pgpa_trove_entry_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_trove_entry_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_trove_entry_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static void pgpa_init_trove_slice(pgpa_trove_slice *tslice);
+static void pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+									pgpa_advice_tag_type tag,
+									pgpa_advice_target *target);
+static void pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash,
+								   pgpa_advice_target *target,
+								   int index);
+static Bitmapset *pgpa_trove_slice_lookup(pgpa_trove_slice *tslice,
+										  pgpa_identifier *rid);
+
+/*
+ * Build a trove of advice from a list of advice items.
+ *
+ * Caller can obtain a list of advice items to pass to this function by
+ * calling pgpa_parse().
+ */
+pgpa_trove *
+pgpa_build_trove(List *advice_items)
+{
+	pgpa_trove *trove = palloc_object(pgpa_trove);
+
+	pgpa_init_trove_slice(&trove->join);
+	pgpa_init_trove_slice(&trove->rel);
+	pgpa_init_trove_slice(&trove->scan);
+
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		switch (item->tag)
+		{
+			case PGPA_TAG_JOIN_ORDER:
+				{
+					pgpa_advice_target *target;
+
+					/*
+					 * For most advice types, each element in the top-level
+					 * list is a separate target, but it's most convenient to
+					 * regard the entirety of a JOIN_ORDER specification as a
+					 * single target. Since it wasn't represented that way
+					 * during parsing, build a surrogate object now.
+					 */
+					target = palloc0_object(pgpa_advice_target);
+					target->ttype = PGPA_TARGET_ORDERED_LIST;
+					target->children = item->targets;
+
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_INDEX_ONLY_SCAN:
+			case PGPA_TAG_INDEX_SCAN:
+			case PGPA_TAG_SEQ_SCAN:
+			case PGPA_TAG_TID_SCAN:
+
+				/*
+				 * Scan advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					/*
+					 * For now, all of our scan types target single relations,
+					 * but in the future this might not be true, e.g. a custom
+					 * scan could replace a join.
+					 */
+					Assert(target->ttype == PGPA_TARGET_IDENTIFIER);
+					pgpa_trove_add_to_slice(&trove->scan,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_FOREIGN_JOIN:
+			case PGPA_TAG_HASH_JOIN:
+			case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			case PGPA_TAG_MERGE_JOIN_PLAIN:
+			case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			case PGPA_TAG_NESTED_LOOP_PLAIN:
+			case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			case PGPA_TAG_SEMIJOIN_UNIQUE:
+
+				/*
+				 * Join strategy advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_PARTITIONWISE:
+			case PGPA_TAG_GATHER:
+			case PGPA_TAG_GATHER_MERGE:
+			case PGPA_TAG_NO_GATHER:
+
+				/*
+				 * Advice about a RelOptInfo relevant to both scans and joins.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->rel,
+											item->tag, target);
+				}
+				break;
+		}
+	}
+
+	return trove;
+}
+
+/*
+ * Search a trove of advice for relevant entries.
+ *
+ * All parameters are input parameters except for *result, which is an output
+ * parameter used to return results to the caller.
+ */
+void
+pgpa_trove_lookup(pgpa_trove *trove, pgpa_trove_lookup_type type,
+				  int nrids, pgpa_identifier *rids, pgpa_trove_result *result)
+{
+	pgpa_trove_slice *tslice;
+	Bitmapset  *indexes;
+
+	Assert(nrids > 0);
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	indexes = pgpa_trove_slice_lookup(tslice, &rids[0]);
+	for (int i = 1; i < nrids; ++i)
+	{
+		Bitmapset  *other_indexes;
+
+		/*
+		 * If the caller is asking about two relations that aren't part of the
+		 * same subquery, they've messed up.
+		 */
+		Assert(strings_equal_or_both_null(rids[0].plan_name,
+										  rids[i].plan_name));
+
+		other_indexes = pgpa_trove_slice_lookup(tslice, &rids[i]);
+		indexes = bms_union(indexes, other_indexes);
+	}
+
+	result->entries = tslice->entries;
+	result->indexes = indexes;
+}
+
+/*
+ * Return all entries in a trove slice to the caller.
+ *
+ * The first two arguments are input arguments, and the remainder are output
+ * arguments.
+ */
+void
+pgpa_trove_lookup_all(pgpa_trove *trove, pgpa_trove_lookup_type type,
+					  pgpa_trove_entry **entries, int *nentries)
+{
+	pgpa_trove_slice *tslice;
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	*entries = tslice->entries;
+	*nentries = tslice->nused;
+}
+
+/*
+ * Convert a trove entry to an item of plan advice that would produce it.
+ */
+char *
+pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	appendStringInfo(&buf, "%s", pgpa_cstring_advice_tag(entry->tag));
+
+	/* JOIN_ORDER tags are transformed by pgpa_build_trove; undo that here */
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, '(');
+	else
+		Assert(entry->target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	pgpa_format_advice_target(&buf, entry->target);
+
+	if (entry->target->itarget != NULL)
+	{
+		appendStringInfoChar(&buf, ' ');
+		pgpa_format_index_target(&buf, entry->target->itarget);
+	}
+
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, ')');
+
+	return buf.data;
+}
+
+/*
+ * Set PGPA_TE_* flags on a set of trove entries.
+ */
+void
+pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
+{
+	int			i = -1;
+
+	while ((i = bms_next_member(indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+
+		entry->flags |= flags;
+	}
+}
+
+/*
+ * Add a new advice target to an existing pgpa_trove_slice object.
+ */
+static void
+pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+						pgpa_advice_tag_type tag,
+						pgpa_advice_target *target)
+{
+	pgpa_trove_entry *entry;
+
+	if (tslice->nused >= tslice->nallocated)
+	{
+		int			new_allocated;
+
+		new_allocated = tslice->nallocated * 2;
+		tslice->entries = repalloc_array(tslice->entries, pgpa_trove_entry,
+										 new_allocated);
+		tslice->nallocated = new_allocated;
+	}
+
+	entry = &tslice->entries[tslice->nused];
+	entry->tag = tag;
+	entry->target = target;
+	entry->flags = 0;
+
+	pgpa_trove_add_to_hash(tslice->hash, target, tslice->nused);
+
+	tslice->nused++;
+}
+
+/*
+ * Update the hash table for a newly-added advice target.
+ */
+static void
+pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash, pgpa_advice_target *target,
+					   int index)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	bool		found;
+
+	/* For non-identifiers, add entries for all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_trove_add_to_hash(hash, child_target, index);
+		}
+		return;
+	}
+
+	/* Sanity checks. */
+	Assert(target->rid.occurrence > 0);
+	Assert(target->rid.alias_name != NULL);
+
+	/* Add an entry for this relation identifier. */
+	key.alias_name = target->rid.alias_name;
+	key.partition_name = target->rid.partrel;
+	key.plan_name = target->rid.plan_name;
+	element = pgpa_trove_entry_insert(hash, key, &found);
+	element->indexes = bms_add_member(element->indexes, index);
+}
+
+/*
+ * Create and initialize a new pgpa_trove_slice object.
+ */
+static void
+pgpa_init_trove_slice(pgpa_trove_slice *tslice)
+{
+	/*
+	 * In an ideal world, we'll make tslice->nallocated big enough that the
+	 * array and hash table will be large enough to contain the number of
+	 * advice items in this trove slice, but a generous default value is not
+	 * good for performance, because pgpa_init_trove_slice() has to zero an
+	 * amount of memory proportional to tslice->nallocated. Hence, we keep the
+	 * starting value quite small, on the theory that advice strings will
+	 * often be relatively short.
+	 */
+	tslice->nallocated = 16;
+	tslice->nused = 0;
+	tslice->entries = palloc_array(pgpa_trove_entry, tslice->nallocated);
+	tslice->hash = pgpa_trove_entry_create(CurrentMemoryContext,
+										   tslice->nallocated, NULL);
+}
+
+/*
+ * Fast hash function for a key consisting of alias_name, partition_name,
+ * and plan_name.
+ */
+static uint32
+pgpa_trove_entry_hash_key(pgpa_trove_entry_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	/* alias_name may not be NULL */
+	sp_len = fasthash_accum_cstring(&hs, key.alias_name);
+
+	/* partition_name and plan_name, however, can be NULL */
+	if (key.partition_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.partition_name);
+	if (key.plan_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.plan_name);
+
+	/*
+	 * hashfn_unstable.h recommends using string length as tweak. It's not
+	 * clear to me what to do if there are multiple strings, so for now I'm
+	 * just using the total of all of the lengths.
+	 */
+	return fasthash_final32(&hs, sp_len);
+}
+
+/*
+ * Look for matching entries.
+ */
+static Bitmapset *
+pgpa_trove_slice_lookup(pgpa_trove_slice *tslice, pgpa_identifier *rid)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	Bitmapset  *result = NULL;
+
+	Assert(rid->occurrence >= 1);
+
+	key.alias_name = rid->alias_name;
+	key.partition_name = rid->partrel;
+	key.plan_name = rid->plan_name;
+
+	element = pgpa_trove_entry_lookup(tslice->hash, key);
+
+	if (element != NULL)
+	{
+		int			i = -1;
+
+		while ((i = bms_next_member(element->indexes, i)) >= 0)
+		{
+			pgpa_trove_entry *entry = &tslice->entries[i];
+
+			/*
+			 * We know that this target or one of its descendents matches the
+			 * identifier on the three key fields above, but we don't know
+			 * which descendent or whether the occurence and schema also
+			 * match.
+			 */
+			if (pgpa_identifier_matches_target(rid, entry->target))
+				result = bms_add_member(result, i);
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
new file mode 100644
index 00000000000..479c3f75778
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -0,0 +1,113 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.h
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_TROVE_H
+#define PGPA_TROVE_H
+
+#include "pgpa_ast.h"
+
+#include "nodes/bitmapset.h"
+
+typedef struct pgpa_trove pgpa_trove;
+
+/*
+ * Flags that can be set on a pgpa_trove_entry to indicate what happened when
+ * trying to plan using advice.
+ *
+ * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_TE_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_TE_MATCH_PARTIAL		0x0001
+#define PGPA_TE_MATCH_FULL			0x0002
+#define PGPA_TE_INAPPLICABLE		0x0004
+#define PGPA_TE_CONFLICTING			0x0008
+#define PGPA_TE_FAILED				0x0010
+
+/*
+ * Each entry in a trove of advice represents the application of a tag to
+ * a single target.
+ */
+typedef struct pgpa_trove_entry
+{
+	pgpa_advice_tag_type tag;
+	pgpa_advice_target *target;
+	int			flags;
+} pgpa_trove_entry;
+
+/*
+ * What kind of information does the caller want to find in a trove?
+ *
+ * PGPA_TROVE_LOOKUP_SCAN means we're looking for scan advice.
+ *
+ * PGPA_TROVE_LOOKUP_JOIN means we're looking for join-related advice.
+ * This includes join order advice, join method advice, and semijoin-uniqueness
+ * advice.
+ *
+ * PGPA_TROVE_LOOKUP_REL means we're looking for general advice about this
+ * a RelOptInfo that may correspond to either a scan or a join. This includes
+ * gather-related advice and partitionwise advice. Note that partitionwise
+ * advice might seem like join advice, but that's not a helpful way of viewing
+ * the matter because (1) partitionwise advice is also relevant at the scan
+ * level and (2) other types of join advice affect only what to do from
+ * join_path_setup_hook, but partitionwise advice affects what to do in
+ * joinrel_setup_hook.
+ */
+typedef enum pgpa_trove_lookup_type
+{
+	PGPA_TROVE_LOOKUP_JOIN,
+	PGPA_TROVE_LOOKUP_REL,
+	PGPA_TROVE_LOOKUP_SCAN
+} pgpa_trove_lookup_type;
+
+/*
+ * This struct is used to store the result of a trove lookup. For each member
+ * of "indexes", the entry at the corresponding offset within "entries" is one
+ * of the results.
+ */
+typedef struct pgpa_trove_result
+{
+	pgpa_trove_entry *entries;
+	Bitmapset  *indexes;
+} pgpa_trove_result;
+
+extern pgpa_trove *pgpa_build_trove(List *advice_items);
+extern void pgpa_trove_lookup(pgpa_trove *trove,
+							  pgpa_trove_lookup_type type,
+							  int nrids,
+							  pgpa_identifier *rids,
+							  pgpa_trove_result *result);
+extern void pgpa_trove_lookup_all(pgpa_trove *trove,
+								  pgpa_trove_lookup_type type,
+								  pgpa_trove_entry **entries,
+								  int *nentries);
+extern char *pgpa_cstring_trove_entry(pgpa_trove_entry *entry);
+extern void pgpa_trove_set_flags(pgpa_trove_entry *entries,
+								 Bitmapset *indexes, int flags);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
new file mode 100644
index 00000000000..4ed22291096
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -0,0 +1,890 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.c
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/plannodes.h"
+#include "parser/parsetree.h"
+
+static void pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+								  bool within_join_problem,
+								  pgpa_join_unroller *join_unroller,
+								  List *active_query_features,
+								  bool beneath_any_gather);
+static Bitmapset *pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+											 pgpa_unrolled_join *ujoin);
+
+static pgpa_query_feature *pgpa_add_feature(pgpa_plan_walker_context *walker,
+											pgpa_qf_type type,
+											Plan *plan);
+
+static void pgpa_qf_add_rti(List *active_query_features, Index rti);
+static void pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids);
+static void pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan,
+								  List *rtable);
+
+static bool pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+										   Index rtable_length,
+										   pgpa_identifier *rt_identifiers,
+										   pgpa_advice_target *target,
+										   bool toplevel);
+static bool pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+												  Index rtable_length,
+												  pgpa_identifier *rt_identifiers,
+												  pgpa_advice_target *target);
+static bool pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+									  pgpa_scan_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+										 pgpa_qf_type type,
+										 Bitmapset *relids);
+static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+									  pgpa_join_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+										   Bitmapset *relids);
+static Index pgpa_walker_get_rti(Index rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid);
+
+/*
+ * Top-level entrypoint for the plan tree walk.
+ *
+ * Populates walker based on a traversal of the Plan trees in pstmt.
+ */
+void
+pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt)
+{
+	ListCell   *lc;
+
+	/* Initialization. */
+	memset(walker, 0, sizeof(pgpa_plan_walker_context));
+	walker->pstmt = pstmt;
+
+	/* Walk the main plan tree. */
+	pgpa_walk_recursively(walker, pstmt->planTree, 0, NULL, NIL, false);
+
+	/* Main plan tree walk won't reach subplans, so walk those. */
+	foreach(lc, pstmt->subplans)
+	{
+		Plan	   *plan = lfirst(lc);
+
+		if (plan != NULL)
+			pgpa_walk_recursively(walker, plan, 0, NULL, NIL, false);
+	}
+}
+
+/*
+ * Main workhorse for the plan tree walk.
+ *
+ * If within_join_problem is true, we encountered a join at some higher level
+ * of the tree walk and haven't yet descended out of the portion of the plan
+ * tree that is part of that same join problem. We're no longer in the same
+ * join problem if (1) we cross into a different subquery or (2) we descend
+ * through an Append or MergeAppend node, below which any further joins would
+ * be partitionwise joins planned separately from the outer join problem.
+ *
+ * If join_unroller != NULL, the join unroller code expects us to find a join
+ * that should be unrolled into that object. This implies that we're within a
+ * join problem, but the reverse is not true: when we've traversed all the
+ * joins but are still looking for the scan that is the leaf of the join tree,
+ * join_unroller will be NULL but within_join_problem will be true.
+ *
+ * Each element of active_query_features corresponds to some item of advice
+ * that needs to enumerate all the relations it affects. We add RTIs we find
+ * during tree traversal to each of these query features.
+ *
+ * If beneath_any_gather == true, some higher level of the tree traversal found
+ * a Gather or Gather Merge node.
+ */
+static void
+pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+					  bool within_join_problem,
+					  pgpa_join_unroller *join_unroller,
+					  List *active_query_features,
+					  bool beneath_any_gather)
+{
+	pgpa_join_unroller *outer_join_unroller = NULL;
+	pgpa_join_unroller *inner_join_unroller = NULL;
+	bool		join_unroller_toplevel = false;
+	List	   *pushdown_query_features = NIL;
+	ListCell   *lc;
+	List	   *extraplans = NIL;
+	List	   *elided_nodes = NIL;
+
+	Assert(within_join_problem || join_unroller == NULL);
+
+	/*
+	 * If this is a Gather or Gather Merge node, directly add it to the list
+	 * of currently-active query features.
+	 *
+	 * Otherwise, check the future_query_features list to see whether this was
+	 * previously identified as a plan node that needs to be treated as a
+	 * query feature.
+	 *
+	 * Note that the caller also has a copy to active_query_features, so we
+	 * can't destructively modify it without making a copy.
+	 */
+	if (IsA(plan, Gather))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER, plan));
+		beneath_any_gather = true;
+	}
+	else if (IsA(plan, GatherMerge))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER_MERGE, plan));
+		beneath_any_gather = true;
+	}
+	else
+	{
+		foreach_ptr(pgpa_query_feature, qf, walker->future_query_features)
+		{
+			if (qf->plan == plan)
+			{
+				active_query_features = list_copy(active_query_features);
+				active_query_features = lappend(active_query_features, qf);
+				walker->future_query_features =
+					list_delete_ptr(walker->future_query_features, plan);
+				break;
+			}
+		}
+	}
+
+	/*
+	 * Find all elided nodes for this Plan node.
+	 */
+	foreach_node(ElidedNode, n, walker->pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_nodes = lappend(elided_nodes, n);
+	}
+
+	/* If we found any elided_nodes, handle them. */
+	if (elided_nodes != NIL)
+	{
+		int			num_elided_nodes = list_length(elided_nodes);
+		ElidedNode *last_elided_node;
+
+		/*
+		 * RTIs for the final -- and thus logically uppermost -- elided node
+		 * should be collected for query features passed down by the caller.
+		 * However, elided nodes act as barriers to query features, which
+		 * means that (1) the remaining elided nodes, if any, should be
+		 * ignored for purposes of query features and (2) the list of active
+		 * query features should be reset to empty so that we do not add RTIs
+		 * from the plan node that is logically beneath the elided node to the
+		 * query features passed down from the caller.
+		 */
+		last_elided_node = list_nth(elided_nodes, num_elided_nodes - 1);
+		pgpa_qf_add_rtis(active_query_features,
+						 pgpa_filter_out_join_relids(last_elided_node->relids,
+													 walker->pstmt->rtable));
+		active_query_features = NIL;
+
+		/*
+		 * If we're within a join problem, the join_unroller is responsible
+		 * for building the scan for the final elided node, so throw it out.
+		 */
+		if (within_join_problem)
+			elided_nodes = list_truncate(elided_nodes, num_elided_nodes - 1);
+
+		/* Build scans for all (or the remaining) elided nodes. */
+		foreach_node(ElidedNode, elided_node, elided_nodes)
+		{
+			(void) pgpa_build_scan(walker, plan, elided_node,
+								   beneath_any_gather, within_join_problem);
+		}
+
+		/*
+		 * If there were any elided nodes, then everything beneath those nodes
+		 * is not part of the same join problem.
+		 *
+		 * In more detail, if an Append or MergeAppend was elided, then a
+		 * partitionwise join was chosen and only a single child survived; if
+		 * a SubqueryScan was elided, the subquery was planned without
+		 * flattening it into the parent.
+		 */
+		within_join_problem = false;
+		join_unroller = NULL;
+	}
+
+	/*
+	 * If we're within a join problem, the join unroller is responsible for
+	 * building any required scan for this node. If not, we do it here.
+	 */
+	if (!within_join_problem)
+		(void) pgpa_build_scan(walker, plan, NULL, beneath_any_gather, false);
+
+	/*
+	 * If this join needs to unrolled but there's no join unroller already
+	 * available, create one.
+	 */
+	if (join_unroller == NULL && pgpa_is_join(plan))
+	{
+		join_unroller = pgpa_create_join_unroller();
+		join_unroller_toplevel = true;
+		within_join_problem = true;
+	}
+
+	/*
+	 * If this join is to be unrolled, pgpa_unroll_join() will return the join
+	 * unroller object that should be passed down when we recurse into the
+	 * outer and inner sides of the plan.
+	 */
+	if (join_unroller != NULL)
+		pgpa_unroll_join(walker, plan, beneath_any_gather, join_unroller,
+						 &outer_join_unroller, &inner_join_unroller);
+
+	/* Add RTIs from the plan node to all active query features. */
+	pgpa_qf_add_plan_rtis(active_query_features, plan, walker->pstmt->rtable);
+
+	/*
+	 * Recurse into the outer and inner subtrees.
+	 *
+	 * As an exception, if this is a ForeignScan, don't recurse. postgres_fdw
+	 * sometimes stores an EPQ recheck plan in plan->leftree, but that's going
+	 * to mention the same set of relations as the ForeignScan itself, and we
+	 * have no way to emit advice targeting the EPQ case vs. the non-EPQ case.
+	 * Moreover, it's not entirely clear what other FDWs might do with the
+	 * left and right subtrees. Maybe some better handling is needed here, but
+	 * for now, we just punt.
+	 */
+	if (!IsA(plan, ForeignScan))
+	{
+		if (plan->lefttree != NULL)
+			pgpa_walk_recursively(walker, plan->lefttree, within_join_problem,
+								  outer_join_unroller, active_query_features,
+								  beneath_any_gather);
+		if (plan->righttree != NULL)
+			pgpa_walk_recursively(walker, plan->righttree, within_join_problem,
+								  inner_join_unroller, active_query_features,
+								  beneath_any_gather);
+	}
+
+	/*
+	 * If we created a join unroller up above, then it's also our join to use
+	 * it to build the final pgpa_unrolled_join, and to destroy the object.
+	 */
+	if (join_unroller_toplevel)
+	{
+		pgpa_unrolled_join *ujoin;
+
+		ujoin = pgpa_build_unrolled_join(walker, join_unroller);
+		walker->toplevel_unrolled_joins =
+			lappend(walker->toplevel_unrolled_joins, ujoin);
+		pgpa_destroy_join_unroller(join_unroller);
+		(void) pgpa_process_unrolled_join(walker, ujoin);
+	}
+
+	/*
+	 * Some plan types can have additional children. Nodes like Append that
+	 * can have any number of children store them in a List; a SubqueryScan
+	 * just has a field for a single additional Plan.
+	 */
+	switch (nodeTag(plan))
+	{
+		case T_Append:
+			{
+				Append	   *aplan = (Append *) plan;
+
+				extraplans = aplan->appendplans;
+				if (bms_is_empty(aplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_MergeAppend:
+			{
+				MergeAppend *maplan = (MergeAppend *) plan;
+
+				extraplans = maplan->mergeplans;
+				if (bms_is_empty(maplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_BitmapAnd:
+			extraplans = ((BitmapAnd *) plan)->bitmapplans;
+			break;
+		case T_BitmapOr:
+			extraplans = ((BitmapOr *) plan)->bitmapplans;
+			break;
+		case T_SubqueryScan:
+
+			/*
+			 * We don't pass down active_query_features across here, because
+			 * those are specific to a subquery level.
+			 */
+			pgpa_walk_recursively(walker, ((SubqueryScan *) plan)->subplan,
+								  0, NULL, NIL, beneath_any_gather);
+			break;
+		case T_CustomScan:
+			extraplans = ((CustomScan *) plan)->custom_plans;
+			break;
+		default:
+			break;
+	}
+
+	/* If we found a list of extra children, iterate over it. */
+	foreach(lc, extraplans)
+	{
+		Plan	   *subplan = lfirst(lc);
+
+		pgpa_walk_recursively(walker, subplan, 0, NULL, pushdown_query_features,
+							  beneath_any_gather);
+	}
+}
+
+/*
+ * Perform final processing of a newly-constructed pgpa_unrolled_join. This
+ * only needs to be called for toplevel pgpa_unrolled_join objects, since it
+ * recurses to sub-joins as needed.
+ *
+ * Our goal is to add the set of inner relids to the relevant join_strategies
+ * list, and to do the same for any sub-joins. To that end, the return value
+ * is the set of relids found beneath the inner side of the join, but it is
+ * expected that the toplevel caller will ignore this.
+ */
+static Bitmapset *
+pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+						   pgpa_unrolled_join *ujoin)
+{
+	Bitmapset  *all_relids = NULL;
+
+	for (int k = 0; k < ujoin->ninner; ++k)
+	{
+		pgpa_join_member *member = &ujoin->inner[k];
+		Bitmapset  *relids;
+
+		if (member->unrolled_join != NULL)
+			relids = pgpa_process_unrolled_join(walker,
+												member->unrolled_join);
+		else
+		{
+			Assert(member->scan != NULL);
+			relids = member->scan->relids;
+		}
+		walker->join_strategies[ujoin->strategy[k]] =
+			lappend(walker->join_strategies[ujoin->strategy[k]], relids);
+		all_relids = bms_add_members(all_relids, relids);
+	}
+
+	return all_relids;
+}
+
+/*
+ * Arrange for the given plan node to be treated as a query feature when the
+ * tree walk reaches it.
+ *
+ * Make sure to only use this for nodes that the tree walk can't have reached
+ * yet!
+ */
+void
+pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+						pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = pgpa_add_feature(walker, type, plan);
+
+	walker->future_query_features =
+		lappend(walker->future_query_features, qf);
+}
+
+/*
+ * Return the last of any elided nodes associated with this plan node ID.
+ *
+ * The last elided node is the one that would have been uppermost in the plan
+ * tree had it not been removed during setrefs processig.
+ */
+ElidedNode *
+pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan)
+{
+	ElidedNode *elided_node = NULL;
+
+	foreach_node(ElidedNode, n, pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_node = n;
+	}
+
+	return elided_node;
+}
+
+/*
+ * Certain plan nodes can refer to a set of RTIs. Extract and return the set.
+ */
+Bitmapset *
+pgpa_relids(Plan *plan)
+{
+	if (IsA(plan, Result))
+		return ((Result *) plan)->relids;
+	else if (IsA(plan, ForeignScan))
+		return ((ForeignScan *) plan)->fs_relids;
+	else if (IsA(plan, Append))
+		return ((Append *) plan)->apprelids;
+	else if (IsA(plan, MergeAppend))
+		return ((MergeAppend *) plan)->apprelids;
+
+	return NULL;
+}
+
+/*
+ * Extract the scanned RTI from a plan node.
+ *
+ * Returns 0 if there isn't one.
+ */
+Index
+pgpa_scanrelid(Plan *plan)
+{
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+		case T_SubqueryScan:
+		case T_FunctionScan:
+		case T_TableFuncScan:
+		case T_ValuesScan:
+		case T_CteScan:
+		case T_NamedTuplestoreScan:
+		case T_WorkTableScan:
+		case T_ForeignScan:
+		case T_CustomScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+			return ((Scan *) plan)->scanrelid;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
+ */
+Bitmapset *
+pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	Bitmapset  *result = NULL;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind != RTE_JOIN)
+			result = bms_add_member(result, rti);
+	}
+
+	return result;
+}
+
+/*
+ * Create a pgpa_query_feature and add it to the list of all query features
+ * for this plan.
+ */
+static pgpa_query_feature *
+pgpa_add_feature(pgpa_plan_walker_context *walker,
+				 pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = palloc0_object(pgpa_query_feature);
+
+	qf->type = type;
+	qf->plan = plan;
+
+	walker->query_features[qf->type] =
+		lappend(walker->query_features[qf->type], qf);
+
+	return qf;
+}
+
+/*
+ * Add a single RTI to each active query feature.
+ */
+static void
+pgpa_qf_add_rti(List *active_query_features, Index rti)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_member(qf->relids, rti);
+	}
+}
+
+/*
+ * Add a set of RTIs to each active query feature.
+ */
+static void
+pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_members(qf->relids, relids);
+	}
+}
+
+/*
+ * Add RTIs directly contained in a plan node to each active query feature,
+ * but filter out any join RTIs, since advice doesn't mention those.
+ */
+static void
+pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan, List *rtable)
+{
+	Bitmapset  *relids;
+	Index		rti;
+
+	if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		relids = pgpa_filter_out_join_relids(relids, rtable);
+		pgpa_qf_add_rtis(active_query_features, relids);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+		pgpa_qf_add_rti(active_query_features, rti);
+}
+
+/*
+ * If we generated plan advice using the provided walker object and array
+ * of identifiers, would we generate the specified tag/target combination?
+ *
+ * If yes, the plan conforms to the advice; if no, it does not. Note that
+ * we have know way of knowing whether the planner was forced to emit a plan
+ * that conformed to the advice or just happened to do so.
+ */
+bool
+pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+						 pgpa_identifier *rt_identifiers,
+						 pgpa_advice_tag_type tag,
+						 pgpa_advice_target *target)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	Bitmapset  *relids = NULL;
+
+	if (tag == PGPA_TAG_JOIN_ORDER)
+	{
+		foreach_ptr(pgpa_unrolled_join, ujoin, walker->toplevel_unrolled_joins)
+		{
+			if (pgpa_walker_join_order_matches(ujoin, rtable_length,
+											   rt_identifiers, target, true))
+				return true;
+		}
+
+		return false;
+	}
+
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		Index		rti;
+
+		rti = pgpa_walker_get_rti(rtable_length, rt_identifiers, &target->rid);
+		relids = bms_make_singleton(rti);
+	}
+	else
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			Index		rti;
+
+			Assert(child_target->ttype == PGPA_TARGET_IDENTIFIER);
+			rti = pgpa_compute_rti_from_identifier(rtable_length,
+												   rt_identifiers,
+												   &child_target->rid);
+			if (rti == 0)
+				elog(ERROR, "cannot determine RTI for advice target");
+			relids = bms_add_member(relids, rti);
+		}
+	}
+
+	switch (tag)
+	{
+		case PGPA_TAG_JOIN_ORDER:
+			/* should have been handled above */
+			pg_unreachable();
+			break;
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_BITMAP_HEAP,
+											 relids);
+		case PGPA_TAG_FOREIGN_JOIN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_FOREIGN,
+											 relids);
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX_ONLY,
+											 relids);
+		case PGPA_TAG_INDEX_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX,
+											 relids);
+		case PGPA_TAG_PARTITIONWISE:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_PARTITIONWISE,
+											 relids);
+		case PGPA_TAG_SEQ_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_SEQ,
+											 relids);
+		case PGPA_TAG_TID_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_TID,
+											 relids);
+		case PGPA_TAG_GATHER:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER,
+												relids);
+		case PGPA_TAG_GATHER_MERGE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER_MERGE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_NON_UNIQUE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_UNIQUE,
+												relids);
+		case PGPA_TAG_HASH_JOIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_HASH_JOIN,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_PLAIN,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MEMOIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_PLAIN,
+											 relids);
+		case PGPA_TAG_NO_GATHER:
+			return pgpa_walker_contains_no_gather(walker, relids);
+	}
+
+	/* should not get here */
+	return false;
+}
+
+/*
+ * Does an unrolled join match the join order specified by an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+							   Index rtable_length,
+							   pgpa_identifier *rt_identifiers,
+							   pgpa_advice_target *target,
+							   bool toplevel)
+{
+	int		nchildren = list_length(target->children);
+
+	Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	/* At toplevel, we allow a prefix match. */
+	if (toplevel)
+	{
+		if (nchildren > ujoin->ninner + 1)
+			return false;
+	}
+	else
+	{
+		if (nchildren != ujoin->ninner + 1)
+			return false;
+	}
+
+	/* Outermost rel must match. */
+	if (!pgpa_walker_join_order_matches_member(&ujoin->outer,
+											   rtable_length,
+											   rt_identifiers,
+											   linitial(target->children)))
+		return false;
+
+	/* Each inner rel must match. */
+	for (int n = 0; n < nchildren - 1; ++n)
+	{
+		pgpa_advice_target *child_target = list_nth(target->children, n + 1);
+
+		if (!pgpa_walker_join_order_matches_member(&ujoin->inner[n],
+												   rtable_length,
+												   rt_identifiers,
+												   child_target))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Does one member of an unrolled join match an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+									  Index rtable_length,
+									  pgpa_identifier *rt_identifiers,
+									  pgpa_advice_target *target)
+{
+	Bitmapset  *relids = NULL;
+
+	if (member->unrolled_join != NULL)
+	{
+		if (target->ttype != PGPA_TARGET_ORDERED_LIST)
+			return false;
+		return pgpa_walker_join_order_matches(member->unrolled_join,
+											  rtable_length,
+											  rt_identifiers,
+											  target,
+											  false);
+	}
+
+	Assert(member->scan != NULL);
+	switch (target->ttype)
+	{
+		case PGPA_TARGET_ORDERED_LIST:
+			/* Could only match an unrolled join */
+			return false;
+
+		case PGPA_TARGET_UNORDERED_LIST:
+			{
+				foreach_ptr(pgpa_advice_target, child_target, target->children)
+				{
+					Index		rti;
+
+					rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+											  &child_target->rid);
+					relids = bms_add_member(relids, rti);
+				}
+				break;
+			}
+
+		case PGPA_TARGET_IDENTIFIER:
+			{
+				Index		rti;
+
+				rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+										  &target->rid);
+				relids = bms_make_singleton(rti);
+				break;
+			}
+	}
+
+	return bms_equal(member->scan->relids, relids);
+}
+
+/*
+ * Does this walker say that the given scan strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+						  pgpa_scan_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *scans = walker->scans[strategy];
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		/*
+		 * XXX. If this is index-related advice, we should also validate that
+		 * the advice target's index target matches the Plan tree.
+		 */
+		if (bms_equal(scan->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does this walker say that the given query feature applies to the given
+ * relid set?
+ */
+static bool
+pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+							 pgpa_qf_type type,
+							 Bitmapset *relids)
+{
+	List	   *query_features = walker->query_features[type];
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (bms_equal(qf->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given join strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+						  pgpa_join_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *join_strategies = walker->join_strategies[strategy];
+
+	foreach_ptr(Bitmapset, jsrelids, join_strategies)
+	{
+		if (bms_equal(jsrelids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given relids should be marked as NO_GATHER?
+ */
+static bool
+pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+							   Bitmapset *relids)
+{
+	return bms_is_subset(relids, walker->no_gather_scans);
+}
+
+/*
+ * Convenience function to convert a relation identifier to an RTI.
+ *
+ * We throw an error here because we expect this to be used on system-generated
+ * advice. Hence, failure here indicates an advice generation bug.
+ */
+static Index
+pgpa_walker_get_rti(Index rtable_length,
+					pgpa_identifier *rt_identifiers,
+					pgpa_identifier *rid)
+{
+	Index		rti;
+
+	rti = pgpa_compute_rti_from_identifier(rtable_length,
+										   rt_identifiers,
+										   rid);
+	if (rti == 0)
+		elog(ERROR, "cannot determine RTI for advice target");
+	return rti;
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
new file mode 100644
index 00000000000..f244f4428a5
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -0,0 +1,122 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.h
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_WALKER_H
+#define PGPA_WALKER_H
+
+#include "pgpa_ast.h"
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+
+/*
+ * We use the term "query feature" to refer to plan nodes that are interesting
+ * in the following way: to generate advice, we'll need to know the set of
+ * same-subquery, non-join RTIs occuring at or below that plan node, without
+ * admixture of parent and child RTIs.
+ *
+ * For example, Gather nodes, desiginated by PGPAQF_GATHER, and Gather Merge
+ * nodes, designated by PGPAQF_GATHER_MERGE, are query features, because we'll
+ * want to admit some kind of advice that describes the portion of the plan
+ * tree that appears beneath those nodes.
+ *
+ * Each semijoin can be implemented either by directly performing a semijoin,
+ * or by making one side unique and then performing a normal join. Either way,
+ * we use a query feature to notice what decision was made, so that we can
+ * describe it by enumerating the RTIs on that side of the join.
+ *
+ * To elaborate on the "no admixture of parent and child RTIs" rule, in all of
+ * these cases, if the entirety of an inheritance hierarchy appears beneath
+ * the query feature, we only want to name the parent table. But it's also
+ * possible to have cases where we must name child tables. This is particularly
+ * likely to happen when partitionwise join is in use, but could happen for
+ * Gather or Gather Merge even without that, if one of those appears below
+ * an Append or MergeAppend node for a single table.
+ */
+typedef enum pgpa_qf_type
+{
+	PGPAQF_GATHER,
+	PGPAQF_GATHER_MERGE,
+	PGPAQF_SEMIJOIN_NON_UNIQUE,
+	PGPAQF_SEMIJOIN_UNIQUE
+	/* update NUM_PGPA_QF_TYPES if you add anything here */
+} pgpa_qf_type;
+
+#define NUM_PGPA_QF_TYPES ((int) PGPAQF_SEMIJOIN_UNIQUE + 1)
+
+/*
+ * For each query feature, we keep track of the feature type and the set of
+ * relids that we found underneath the relevant plan node. See the comments
+ * on pgpa_qf_type, above, for additional details.
+ */
+typedef struct pgpa_query_feature
+{
+	pgpa_qf_type type;
+	Plan	   *plan;
+	Bitmapset  *relids;
+} pgpa_query_feature;
+
+/*
+ * Context object for plan tree walk.
+ *
+ * pstmt is the PlannedStmt we're studying.
+ *
+ * scans is an array of lists of pgpa_scan objects. The array is indexed by
+ * the scan's pgpa_scan_strategy.
+ *
+ * no_gather_scans is the set of scan RTIs that do not appear beneath any
+ * Gather or Gather Merge node.
+ *
+ * toplevel_unrolled_joins is a list of all pgpa_unrolled_join objects that
+ * are not a child of some other pgpa_unrolled_join.
+ *
+ * join_strategy is an array of lists of Bitmapset objects. Each Bitmapset
+ * is the set of relids that appears on the inner side of some join (excluding
+ * RTIs from partition children and subqueries). The array is indexed by
+ * pgpa_join_strategy.
+ *
+ * query_features is an array lists of pgpa_query_feature objects, indexed
+ * by pgpa_qf_type.
+ *
+ * future_query_features is only used during the plan tree walk and should
+ * be empty when the tree walk concludes. It is a list of pgpa_query_feature
+ * objects for Plan nodes that the plan tree walk has not yet encountered;
+ * when encountered, they will be moved to the list of active query features
+ * that is propagated via the call stack.
+ */
+typedef struct pgpa_plan_walker_context
+{
+	PlannedStmt *pstmt;
+	List	   *scans[NUM_PGPA_SCAN_STRATEGY];
+	Bitmapset  *no_gather_scans;
+	List	   *toplevel_unrolled_joins;
+	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
+	List	   *query_features[NUM_PGPA_QF_TYPES];
+	List	   *future_query_features;
+} pgpa_plan_walker_context;
+
+extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
+							 PlannedStmt *pstmt);
+
+extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+									pgpa_qf_type type,
+									Plan *plan);
+
+extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
+extern Bitmapset *pgpa_relids(Plan *plan);
+extern Index pgpa_scanrelid(Plan *plan);
+extern Bitmapset *pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable);
+
+extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+									 pgpa_identifier *rt_identifiers,
+									 pgpa_advice_tag_type tag,
+									 pgpa_advice_target *target);
+
+#endif
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
new file mode 100644
index 00000000000..58280043913
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_order.sql b/contrib/pg_plan_advice/sql/join_order.sql
new file mode 100644
index 00000000000..5aa2fc62d34
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_order.sql
@@ -0,0 +1,96 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
+
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/sql/join_strategy.sql b/contrib/pg_plan_advice/sql/join_strategy.sql
new file mode 100644
index 00000000000..8eb823f1c0e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_strategy.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/local_collector.sql b/contrib/pg_plan_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..08502573c8f
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/local_collector.sql
@@ -0,0 +1,41 @@
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_plan_advice/sql/partitionwise.sql b/contrib/pg_plan_advice/sql/partitionwise.sql
new file mode 100644
index 00000000000..e42c0611760
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/partitionwise.sql
@@ -0,0 +1,78 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
new file mode 100644
index 00000000000..25416a75f46
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -0,0 +1,195 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+COMMIT;
+
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+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 > 0;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+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;
+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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+COMMIT;
+
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/syntax.sql b/contrib/pg_plan_advice/sql/syntax.sql
new file mode 100644
index 00000000000..8bc1b71bebe
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/syntax.sql
@@ -0,0 +1,42 @@
+LOAD 'pg_plan_advice';
+
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+SET pg_plan_advice.advice = '()';
+SET pg_plan_advice.advice = '123';
+
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
diff --git a/contrib/pg_plan_advice/t/001_regress.pl b/contrib/pg_plan_advice/t/001_regress.pl
new file mode 100644
index 00000000000..735f54d57ec
--- /dev/null
+++ b/contrib/pg_plan_advice/t/001_regress.pl
@@ -0,0 +1,147 @@
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_plan_advice to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+#
+# We run with pg_plan_advice.shared_collection_limit set to ensure that the
+# plan tree walker code runs against every query in the regression tests. If
+# we're unable to properly analyze any of those plan trees, this test should fail.
+#
+# We set pg_plan_advice.advice to an advice string that will cause the advice
+# trove to be populated with a few entries of various sorts, but which we do
+# not expect to match anything in the regression test queries. This way, the
+# planner hooks will be called, improving code coverage, but no plans should
+# actually change.
+#
+# pg_plan_advice.always_explain_supplied_advice=false is needed to avoid breaking
+# regression test queries that use EXPLAIN. In the real world, it seems like
+# users will want EXPLAIN output to show supplied advice so that it's clear
+# whether normal planner behavior has been altered, but here that's undesirable.
+$node->append_conf('postgresql.conf', <<EOM);
+pg_plan_advice.shared_collection_limit=1000000
+shared_preload_libraries=pg_plan_advice
+pg_plan_advice.advice='SEQ_SCAN(entirely_fictitious) HASH_JOIN(total_fabrication) GATHER(completely_imaginary)'
+pg_plan_advice.always_explain_supplied_advice=false
+EOM
+$node->start;
+
+my $srcdir = abs_path("../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+# Create the extension so we can access the collector
+$node->safe_psql('postgres', 'CREATE EXTENSION pg_plan_advice');
+
+# Verify that a large amount of advice was collected
+my $all_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice();
+EOM
+cmp_ok($all_query_count, '>', 35000, "copious advice collected");
+
+# Verify that lots of different advice strings were collected
+my $distinct_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM
+	(SELECT DISTINCT advice FROM pg_get_collected_shared_advice());
+EOM
+cmp_ok($distinct_query_count, '>', 3000, "diverse advice collected");
+
+# We want to test for the presence of our known tags in the collected advice.
+# Put all tags into the hash that follows; map any tags that aren't tested
+# by the core regression tests to 0, and others to 1.
+my %tag_map = (
+	BITMAP_HEAP_SCAN => 1,
+	FOREIGN_JOIN => 0,
+	GATHER => 1,
+	GATHER_MERGE => 1,
+	HASH_JOIN => 1,
+	INDEX_ONLY_SCAN => 1,
+	INDEX_SCAN => 1,
+	JOIN_ORDER => 1,
+	MERGE_JOIN_MATERIALIZE => 1,
+	MERGE_JOIN_PLAIN => 1,
+	NESTED_LOOP_MATERIALIZE => 1,
+	NESTED_LOOP_MEMOIZE => 1,
+	NESTED_LOOP_PLAIN => 1,
+	NO_GATHER => 1,
+	PARTITIONWISE => 1,
+	SEMIJOIN_NON_UNIQUE => 1,
+	SEMIJOIN_UNIQUE => 1,
+	SEQ_SCAN => 1,
+	TID_SCAN => 1,
+);
+for my $tag (sort keys %tag_map)
+{
+	my $checkit = $tag_map{$tag};
+
+	# Search for the given tag. This is not entirely robust: it could get thrown
+	# off by a table alias such as "FOREIGN_JOIN(", but that probably won't
+	# happen in the core regression tests.
+	my $tag_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice()
+	WHERE advice LIKE '%$tag(%'
+EOM
+
+	# Check that the tag got a non-trivial amount of use, unless told otherwise.
+	cmp_ok($tag_count, '>', 10, "multiple uses of $tag") if $checkit;
+
+	# Regardless, note the exact count in the log, for human consumption.
+	note("found $tag_count advice strings containing $tag");
+}
+
+# Trigger a partial cleanup of the shared advice collector, and then a full
+# cleanup.
+$node->safe_psql('postgres', <<EOM);
+SET pg_plan_advice.shared_collection_limit=500;
+SELECT * FROM pg_clear_collected_shared_advice();
+EOM
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 6c82aa9511e..a4dcf344bd3 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3929,6 +3929,43 @@ pg_wc_probefunc
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgpa_collected_advice
+pgpa_advice_item
+pgpa_advice_tag_type
+pgpa_advice_target
+pgpa_identifier
+pgpa_index_target
+pgpa_index_type
+pgpa_itm_type
+pgpa_join_class
+pgpa_join_member
+pgpa_join_state
+pgpa_join_strategy
+pgpa_join_unroller
+pgpa_local_advice
+pgpa_local_advice_chunk
+pgpa_output_context
+pgpa_plan_walker_context
+pgpa_planner_state
+pgpa_qf_type
+pgpa_query_feature
+pgpa_ri_checker
+pgpa_ri_checker_key
+pgpa_scan
+pgpa_scan_strategy
+pgpa_shared_advice
+pgpa_shared_advice_chunk
+pgpa_shared_state
+pgpa_target_type
+pgpa_trove
+pgpa_trove_entry
+pgpa_trove_entry_element
+pgpa_trove_entry_hash
+pgpa_trove_entry_key
+pgpa_trove_lookup_type
+pgpa_trove_result
+pgpa_trove_slice
+pgpa_unrolled_join
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-23 00:43  Dian Fay <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Dian Fay @ 2025-11-23 00:43 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; Matheus Alcantara <[email protected]>; +Cc: Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Tue Nov 18, 2025 at 11:19 AM EST, Robert Haas wrote:
> Here's v4. This version has some bug fixes and test case changes to
> 0005 and 0006, with the goal of getting CI to pass cleanly (which it
> now does for me, but let's see if cfbot agrees).

Thanks for working on this, Robert! I think the design seems solid (and
very powerful) from a user perspective. I was curious what would happen
with row-level security interactions so I tried it out on a toy example
I put together a while back. I found one case where scan advice fails on
an intentionally naive/bad policy implementation, but I'm not sure why
and it seems like the kind of weird corner case that might be useful to
reason about. See attached for the setup script, then:

set pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(item public.item_tags_idx)';
set item_reader.allowed_tags = '{alpha,beta}';
set role item_reader;

explain (plan_advice, analyze, verbose, costs, timing)
select * from item
where value ilike 'a%' and tags && array[1];

Seq Scan on public.item  (cost=0.00..41777312.00 rows=54961 width=67) (actual time=2.947..8603.333 rows=6762.00 loops=1)
 Disabled: true
 Output: item.id, item.value, item.tags
 Filter: (EXISTS(SubPlan exists_1) AND (item.value ~~* 'a%'::text) AND (item.tags && '{1}'::integer[]))
 Rows Removed by Filter: 993238
 Buffers: shared hit=1012312
 SubPlan exists_1
   ->  Seq Scan on public.tag  (cost=0.00..41.75 rows=1 width=0) (actual time=0.008..0.008 rows=0.21 loops=1000000)
         Filter: ((current_setting('item_reader.allowed_tags'::text) IS NOT NULL) AND ((current_setting('item_reader.allowed_tags'::text))::text[] @> ARRAY[tag.name]) AND (item.tags @> ARRAY[tag.id]))
         Rows Removed by Filter: 18
         Buffers: shared hit=1000000
Planning Time: 1.168 ms
Supplied Plan Advice:
 BITMAP_HEAP_SCAN(item public.item_tags_idx) /* matched, failed */
Generated Plan Advice:
 SEQ_SCAN(item tag@exists_1)
 NO_GATHER(item tag@exists_1)
Execution Time: 8603.615 ms

Since the policies don't contain any execution boundaries, all the quals
should be going into a single bucket for planning if I understand the
process correctly. The bitmap heap scan should be a candidate given the
`tags &&` predicate (and indeed if I switch to a privileged role, the
advice matches successfully without any policies in the mix), but gdb
shows the walker bouncing out of pgpa_walker_contains_scan without any
candidate scans for the BITMAP_HEAP_SCAN strategy.

I do want to avoid getting bikesheddy about the advice language so I'll
forbear from syntax discussion, but one design thought with lower-level
implications did occur to me as I was playing with this: it might be
useful in some situations to influence the planner _away_ from known
worse paths while leaving it room to decide on the best other option. I
think the work you did in path management should make this pretty
straightforward for join and scan strategies, since it looks like you've
basically made the enable_* gucs a runtime-configurable bitmask (which
seems like a perfectly reasonable approach to my "have done some source
diving but not an internals hacker" eyes), and could disable one as
easily as forcing one.

"Don't use this one index" sounds more fiddly to implement, but also
less valuable since in that case you probably already know which other
index it should be using.


Attachments:

  [application/sql] rls-demo-item-tags.sql (1.2K, 2-rls-demo-item-tags.sql)
  download

^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-24 16:14  Robert Haas <[email protected]>
  parent: Dian Fay <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-11-24 16:14 UTC (permalink / raw)
  To: Dian Fay <[email protected]>; +Cc: Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sat, Nov 22, 2025 at 7:43 PM Dian Fay <[email protected]> wrote:
> Thanks for working on this, Robert!

Thanks for looking at it! I was hoping for a bit more in the way of
responses by now, honestly.

> Since the policies don't contain any execution boundaries, all the quals
> should be going into a single bucket for planning if I understand the
> process correctly. The bitmap heap scan should be a candidate given the
> `tags &&` predicate (and indeed if I switch to a privileged role, the
> advice matches successfully without any policies in the mix), but gdb
> shows the walker bouncing out of pgpa_walker_contains_scan without any
> candidate scans for the BITMAP_HEAP_SCAN strategy.

I can understand why it seems that way, but when I try setting
enable_seqscan=false instead of using pg_plan_advice, I get exactly
the same result. I think this is actually a great example both of why
this is actually a very powerful tool and also why it has the
potential to be really confusing. The power comes from the fact that
you can find out whether the planner thinks that the thing you want to
do is even possible. In this case, that's easy anyway because the
example is simple enough, but sometimes you can't set
enable_seqscan=false or similar because it would change too many other
things in the plan at that same time and you wouldn't be able to
compare. In those situations, this figures to be useful. However, all
this can do is tell you that the answer to the question "is this a
possible plan shape?" is "no". It cannot tell you why, and you may
easily find the result counterintuitive.

And honestly, this is one of the things I'm worried about if we go
forward with this, that we'll get a ton of people who think it doesn't
work because it doesn't force the planner to do things which the
planner rejects on non-cost considerations. We're going to need really
good documentation to explain to people that if you use this to try to
force a plan and you can't, that's not a bug, that's the planner
telling you that that plan shape is not able to be considered for some
reason. That won't keep people from complaining about things that
aren't really bugs, but at least it will mean that there's a link we
can give them to explain why the way they're thinking about it is
incorrect. However, that will just beg the next question of WHY the
planner doesn't think a certain plan can be considered, and honestly,
I've found over the years that I often need to resort to the source
code to answer those kinds of questions. People who are not good at
reading C source code are not going to like that answer very much, but
I still think it's better if they know THAT the planner thinks the
plan shape is impossible even if we can't tell them WHY the planner
thinks that the plan shape is impossible. We probably will want to
document at least some of the common reasons why this happens, to cut
down on getting the same questions over and over again.

In this particular case, I think the problem is that the user-supplied
qual item.tags @> ARRAY[id] is not leakproof and therefore must be
tested after the security qual. There's no way to use a Bitmap Heap
Scan without reversing the order of those tests.

> I do want to avoid getting bikesheddy about the advice language so I'll
> forbear from syntax discussion, but one design thought with lower-level
> implications did occur to me as I was playing with this: it might be
> useful in some situations to influence the planner _away_ from known
> worse paths while leaving it room to decide on the best other option. I
> think the work you did in path management should make this pretty
> straightforward for join and scan strategies, since it looks like you've
> basically made the enable_* gucs a runtime-configurable bitmask (which
> seems like a perfectly reasonable approach to my "have done some source
> diving but not an internals hacker" eyes), and could disable one as
> easily as forcing one.

I mostly agree. Saying not to use a sequential scan on a certain
table, or not to use a particular index, or not to use a particular
join method seem like things that would be potentially useful, and
they would be straightforward generalizations of what the code already
does. For me, that would principally be a way to understand better why
the planner chose what it did. I often wonder what the planner's
second choice would have been, but I don't just want the plan with the
second-cheapest overall cost, because that will be something just
trivially different. I want the cheapest plan that excludes some key
element of the current plan, so I can see a meaningfully different
alternative.

That said, I don't see this being a general thing that would make
sense across all of the tags that pg_plan_advice supports. For
example, NO_JOIN_ORDER() sounds hard to implement and largely useless.

The main reason I haven't done this is that I want to keep the focus
on plan stability, or said differently, on things that can properly
round-trip. You should be able to run a query with EXPLAIN
(PLAN_ADVICE), then set pg_plan_advice.advice to the resulting string,
rerun the query, and get the same plan with all of the advice
successfully matching. Since EXPLAIN (PLAN_ADVICE) would never emit
these proposed negative tags, we'd need to think a little bit harder
about how that stuff should be tested. That's not necessarily a big
deal or anything, but I didn't think it was an essential element of
the initial scope, so I left it out. I'm happy to add it in at some
point, or for someone else to do so, but not until this much is
working well.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-11-30 03:16  Dian Fay <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Dian Fay @ 2025-11-30 03:16 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon Nov 24, 2025 at 11:14 AM EST, Robert Haas wrote:
> On Sat, Nov 22, 2025 at 7:43 PM Dian Fay <[email protected]> wrote:
>> Since the policies don't contain any execution boundaries, all the quals
>> should be going into a single bucket for planning if I understand the
>> process correctly. The bitmap heap scan should be a candidate given the
>> `tags &&` predicate (and indeed if I switch to a privileged role, the
>> advice matches successfully without any policies in the mix), but gdb
>> shows the walker bouncing out of pgpa_walker_contains_scan without any
>> candidate scans for the BITMAP_HEAP_SCAN strategy.
>
> In this particular case, I think the problem is that the user-supplied
> qual item.tags @> ARRAY[id] is not leakproof and therefore must be
> tested after the security qual. There's no way to use a Bitmap Heap
> Scan without reversing the order of those tests.

Right, I keep forgetting the functions underneath those array operators
aren't leakproof. Thanks for digging.

> And honestly, this is one of the things I'm worried about if we go
> forward with this, that we'll get a ton of people who think it doesn't
> work because it doesn't force the planner to do things which the
> planner rejects on non-cost considerations. We're going to need really
> good documentation to explain to people that if you use this to try to
> force a plan and you can't, that's not a bug, that's the planner
> telling you that that plan shape is not able to be considered for some
> reason.

Once we're closer to consensus on pg_plan_advice or something like it
landing, I'm interested in helping out on this end of things!





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-05 19:57  Robert Haas <[email protected]>
  parent: Dian Fay <[email protected]>
  0 siblings, 4 replies; 133+ messages in thread

From: Robert Haas @ 2025-12-05 19:57 UTC (permalink / raw)
  To: Dian Fay <[email protected]>; +Cc: Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sat, Nov 29, 2025 at 10:17 PM Dian Fay <[email protected]> wrote:
> Once we're closer to consensus on pg_plan_advice or something like it
> landing, I'm interested in helping out on this end of things!

Thanks!

014f9a831a320666bf2195949f41710f970c54ad removes the need for what was
previously 0004, so here is a new patch series with that dropped, to
avoid confusing cfbot or human reviewers.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v5-0001-Store-information-about-range-table-flattening-in.patch (7.9K, 2-v5-0001-Store-information-about-range-table-flattening-in.patch)
  download | inline diff:
From d853443ef8c92a494f9d7493d6a5597af50467e0 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 12:00:18 -0400
Subject: [PATCH v5 1/6] Store information about range-table flattening in the
 final plan.

Suppose that we're currently planning a query and, when that same
query was previously planned and executed, we learned something about
how a certain table within that query should be planned. We want to
take note when that same table is being planned during the current
planning cycle, but this is difficult to do, because the RTI of the
table from the previous plan won't necessarily be equal to the RTI
that we see during the current planning cycle. This is because each
subquery has a separate range table during planning, but these are
flattened into one range table when constructing the final plan,
changing RTIs.

Commit 8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0 allows us to match up
subqueries seen in the previous planning cycles with the subqueries
currently being planned just by comparing textual names, but that's
not quite enough to let us deduce anything about individual tables,
because we don't know where each subquery's range table appears in
the final, flattened range table.

To fix that, store a list of SubPlanRTInfo objects in the final
planned statement, each including the name of the subplan, the offset
at which it begins in the flattened range table, and whether or not
it was a dummy subplan -- if it was, some RTIs may have been dropped
from the final range table, but also there's no need to control how
a dummy subquery gets planned. The toplevel subquery has no name and
always begins at rtoffset 0, so we make no entry for it.

This commit teaches pg_overexplain'e RANGE_TABLE option to make use
of this new data to display the subquery name for each range table
entry.

NOTE TO REVIEWERS: If there's a clean way to make pg_overexplain display
this information without the new infrastructure provided by this patch,
then this patch is unnecessary. I thought there would be a way to do
that, but I couldn't figure anything out: there seems to be nothing that
records in the final PlannedStmt where subquery's range table ends and
the next one begins. In practice, one could usually figure it out by
matching up tables by relation OID, but that's neither clean nor
theoretically sound.
---
 contrib/pg_overexplain/pg_overexplain.c | 36 +++++++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    |  1 +
 src/backend/optimizer/plan/setrefs.c    | 20 ++++++++++++++
 src/include/nodes/pathnodes.h           |  3 +++
 src/include/nodes/plannodes.h           | 17 ++++++++++++
 src/tools/pgindent/typedefs.list        |  1 +
 6 files changed, 78 insertions(+)

diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index fcdc17012da..1c4c796adb2 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -395,6 +395,8 @@ static void
 overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 {
 	Index		rti;
+	ListCell   *lc_subrtinfo = list_head(plannedstmt->subrtinfos);
+	SubPlanRTInfo *rtinfo = NULL;
 
 	/* Open group, one entry per RangeTblEntry */
 	ExplainOpenGroup("Range Table", "Range Table", false, es);
@@ -405,6 +407,18 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 		RangeTblEntry *rte = rt_fetch(rti, plannedstmt->rtable);
 		char	   *kind = NULL;
 		char	   *relkind;
+		SubPlanRTInfo *next_rtinfo;
+
+		/* Advance to next SubRTInfo, if it's time. */
+		if (lc_subrtinfo != NULL)
+		{
+			next_rtinfo = lfirst(lc_subrtinfo);
+			if (rti > next_rtinfo->rtoffset)
+			{
+				rtinfo = next_rtinfo;
+				lc_subrtinfo = lnext(plannedstmt->subrtinfos, lc_subrtinfo);
+			}
+		}
 
 		/* NULL entries are possible; skip them */
 		if (rte == NULL)
@@ -469,6 +483,28 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 			ExplainPropertyBool("In From Clause", rte->inFromCl, es);
 		}
 
+		/*
+		 * Indicate which subplan is the origin of which RTE. Note dummy
+		 * subplans. Here again, we crunch more onto one line in text format.
+		 */
+		if (rtinfo != NULL)
+		{
+			if (es->format == EXPLAIN_FORMAT_TEXT)
+			{
+				if (!rtinfo->dummy)
+					ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				else
+					ExplainPropertyText("Subplan",
+										psprintf("%s (dummy)",
+												 rtinfo->plan_name), es);
+			}
+			else
+			{
+				ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				ExplainPropertyBool("Subplan Is Dummy", rtinfo->dummy, es);
+			}
+		}
+
 		/* rte->alias is optional; rte->eref is requested */
 		if (rte->alias != NULL)
 			overexplain_alias("Alias", rte->alias, es);
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index fd77334e5fd..5436f269655 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -607,6 +607,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->unprunableRelids = bms_difference(glob->allRelids,
 											  glob->prunableRelids);
 	result->permInfos = glob->finalrteperminfos;
+	result->subrtinfos = glob->subrtinfos;
 	result->resultRelations = glob->resultRelations;
 	result->appendRelations = glob->appendRelations;
 	result->subplans = glob->subplans;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index ccdc9bc264a..adabae09a23 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -399,6 +399,26 @@ add_rtes_to_flat_rtable(PlannerInfo *root, bool recursing)
 	Index		rti;
 	ListCell   *lc;
 
+	/*
+	 * Record enough information to make it possible for code that looks at
+	 * the final range table to understand how it was constructed. (If
+	 * finalrtable is still NIL, then this is the very topmost PlannerInfo,
+	 * which will always have plan_name == NULL and rtoffset == 0; we omit the
+	 * degenerate list entry.)
+	 */
+	if (root->glob->finalrtable != NIL)
+	{
+		SubPlanRTInfo *rtinfo = makeNode(SubPlanRTInfo);
+
+		rtinfo->plan_name = root->plan_name;
+		rtinfo->rtoffset = list_length(root->glob->finalrtable);
+
+		/* When recursing = true, it's an unplanned or dummy subquery. */
+		rtinfo->dummy = recursing;
+
+		root->glob->subrtinfos = lappend(root->glob->subrtinfos, rtinfo);
+	}
+
 	/*
 	 * Add the query's own RTEs to the flattened rangetable.
 	 *
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 46a8655621d..3782bc64075 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -135,6 +135,9 @@ typedef struct PlannerGlobal
 	/* "flat" list of RTEPermissionInfos */
 	List	   *finalrteperminfos;
 
+	/* list of SubPlanRTInfo nodes */
+	List	   *subrtinfos;
+
 	/* "flat" list of PlanRowMarks */
 	List	   *finalrowmarks;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index c4393a94321..1526dd2ec6b 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -131,6 +131,9 @@ typedef struct PlannedStmt
 	 */
 	List	   *subplans;
 
+	/* a list of SubPlanRTInfo objects */
+	List	   *subrtinfos;
+
 	/* indices of subplans that require REWIND */
 	Bitmapset  *rewindPlanIDs;
 
@@ -1821,4 +1824,18 @@ typedef enum MonotonicFunction
 	MONOTONICFUNC_BOTH = MONOTONICFUNC_INCREASING | MONOTONICFUNC_DECREASING,
 } MonotonicFunction;
 
+/*
+ * SubPlanRTInfo
+ *
+ * Information about which range table entries came from which subquery
+ * planning cycles.
+ */
+typedef struct SubPlanRTInfo
+{
+	NodeTag		type;
+	const char *plan_name;
+	Index		rtoffset;
+	bool		dummy;
+} SubPlanRTInfo;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c1ad80a418d..40d02913080 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2899,6 +2899,7 @@ SubLink
 SubLinkType
 SubOpts
 SubPlan
+SubPlanRTInfo
 SubPlanState
 SubRelInfo
 SubRemoveRels
-- 
2.51.0



  [application/octet-stream] v5-0002-Store-information-about-elided-nodes-in-the-final.patch (9.3K, 3-v5-0002-Store-information-about-elided-nodes-in-the-final.patch)
  download | inline diff:
From aa02829f3e7ac92b2cff259bce89260eabe49d6a Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:42 -0400
Subject: [PATCH v5 2/6] Store information about elided nodes in the final
 plan.

An extension (or core code) might want to reconstruct the planner's
choice of join order from the final plan. To do so, it must be possible
to find all of the RTIs that were part of the join problem in that plan.
The previous commit, together with the earlier work in
8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0, is enough to let us match up
RTIs we see in the final plan with RTIs that we see during the planning
cycle, but we still have a problem if the planner decides to drop some
RTIs out of the final plan altogether.

To fix that, when setrefs.c removes a SubqueryScan, single-child Append,
or single-child MergeAppend from the final Plan tree, record the type of
the removed node and the RTIs that the removed node would have scanned
in the final plan tree. It would be natural to record this information
on the child of the removed plan node, but that would require adding
an additional pointer field to type Plan, which seems undesirable.
So, instead, store the information in a separate list that the
executor need never consult, and use the plan_node_id to identify
the plan node with which the removed node is logically associated.

Also, update pg_overexplain to display these details.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 39 ++++++++++++++
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/plan/setrefs.c          | 52 ++++++++++++++++++-
 src/include/nodes/pathnodes.h                 |  3 ++
 src/include/nodes/plannodes.h                 | 17 ++++++
 src/tools/pgindent/typedefs.list              |  1 +
 7 files changed, 114 insertions(+), 3 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 55d34666d87..ca9a23ea61f 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -452,6 +452,8 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
  Seq Scan on daucus vegetables
    Filter: (genus = 'daucus'::text)
    Scan RTI: 2
+   Elided Node Type: Append
+   Elided Node RTIs: 1
  RTI 1 (relation, inherited, in-from-clause):
    Eref: vegetables (id, name, genus)
    Relation: vegetables
@@ -465,7 +467,7 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 2
-(16 rows)
+(18 rows)
 
 -- Also test a case that involves a write.
 EXPLAIN (RANGE_TABLE, COSTS OFF)
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index 1c4c796adb2..e54f8cfc332 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -191,6 +191,8 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 	 */
 	if (options->range_table)
 	{
+		bool		opened_elided_nodes = false;
+
 		switch (nodeTag(plan))
 		{
 			case T_SeqScan:
@@ -251,6 +253,43 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 			default:
 				break;
 		}
+
+		foreach_node(ElidedNode, n, es->pstmt->elidedNodes)
+		{
+			char	   *elidednodetag;
+
+			if (n->plan_node_id != plan->plan_node_id)
+				continue;
+
+			if (!opened_elided_nodes)
+			{
+				ExplainOpenGroup("Elided Nodes", "Elided Nodes", false, es);
+				opened_elided_nodes = true;
+			}
+
+			switch (n->elided_type)
+			{
+				case T_Append:
+					elidednodetag = "Append";
+					break;
+				case T_MergeAppend:
+					elidednodetag = "MergeAppend";
+					break;
+				case T_SubqueryScan:
+					elidednodetag = "SubqueryScan";
+					break;
+				default:
+					elidednodetag = psprintf("%d", n->elided_type);
+					break;
+			}
+
+			ExplainOpenGroup("Elided Node", NULL, true, es);
+			ExplainPropertyText("Elided Node Type", elidednodetag, es);
+			overexplain_bitmapset("Elided Node RTIs", n->relids, es);
+			ExplainCloseGroup("Elided Node", NULL, true, es);
+		}
+		if (opened_elided_nodes)
+			ExplainCloseGroup("Elided Nodes", "Elided Nodes", false, es);
 	}
 }
 
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 5436f269655..2755ef00e90 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -618,6 +618,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->paramExecTypes = glob->paramExecTypes;
 	/* utilityStmt should be null, but we might as well copy it */
 	result->utilityStmt = parse->utilityStmt;
+	result->elidedNodes = glob->elidedNodes;
 	result->stmt_location = parse->stmt_location;
 	result->stmt_len = parse->stmt_len;
 
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index adabae09a23..23a00d452b7 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -211,6 +211,9 @@ static List *set_windowagg_runcondition_references(PlannerInfo *root,
 												   List *runcondition,
 												   Plan *plan);
 
+static void record_elided_node(PlannerGlobal *glob, int plan_node_id,
+							   NodeTag elided_type, Bitmapset *relids);
+
 
 /*****************************************************************************
  *
@@ -1460,10 +1463,17 @@ set_subqueryscan_references(PlannerInfo *root,
 
 	if (trivial_subqueryscan(plan))
 	{
+		Index		scanrelid;
+
 		/*
 		 * We can omit the SubqueryScan node and just pull up the subplan.
 		 */
 		result = clean_up_removed_plan_level((Plan *) plan, plan->subplan);
+
+		/* Remember that we removed a SubqueryScan */
+		scanrelid = plan->scan.scanrelid + rtoffset;
+		record_elided_node(root->glob, plan->subplan->plan_node_id,
+						   T_SubqueryScan, bms_make_singleton(scanrelid));
 	}
 	else
 	{
@@ -1891,7 +1901,17 @@ set_append_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(aplan->appendplans);
 
 		if (p->parallel_aware == aplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) aplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) aplan, p);
+
+			/* Remember that we removed an Append */
+			record_elided_node(root->glob, p->plan_node_id, T_Append,
+							   offset_relid_set(aplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -1959,7 +1979,17 @@ set_mergeappend_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(mplan->mergeplans);
 
 		if (p->parallel_aware == mplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) mplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) mplan, p);
+
+			/* Remember that we removed a MergeAppend */
+			record_elided_node(root->glob, p->plan_node_id, T_MergeAppend,
+							   offset_relid_set(mplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -3774,3 +3804,21 @@ extract_query_dependencies_walker(Node *node, PlannerInfo *context)
 	return expression_tree_walker(node, extract_query_dependencies_walker,
 								  context);
 }
+
+/*
+ * Record some details about a node removed from the plan during setrefs
+ * procesing, for the benefit of code trying to reconstruct planner decisions
+ * from examination of the final plan tree.
+ */
+static void
+record_elided_node(PlannerGlobal *glob, int plan_node_id,
+				   NodeTag elided_type, Bitmapset *relids)
+{
+	ElidedNode *n = makeNode(ElidedNode);
+
+	n->plan_node_id = plan_node_id;
+	n->elided_type = elided_type;
+	n->relids = relids;
+
+	glob->elidedNodes = lappend(glob->elidedNodes, n);
+}
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 3782bc64075..42c146d802a 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -159,6 +159,9 @@ typedef struct PlannerGlobal
 	/* type OIDs for PARAM_EXEC Params */
 	List	   *paramExecTypes;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/* highest PlaceHolderVar ID assigned */
 	Index		lastPHId;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 1526dd2ec6b..5d0520d5e58 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -152,6 +152,9 @@ typedef struct PlannedStmt
 	/* non-null if this is utility stmt */
 	Node	   *utilityStmt;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/*
 	 * DefElem objects added by extensions, e.g. using planner_shutdown_hook
 	 *
@@ -1838,4 +1841,18 @@ typedef struct SubPlanRTInfo
 	bool		dummy;
 } SubPlanRTInfo;
 
+/*
+ * ElidedNode
+ *
+ * Information about nodes elided from the final plan tree: trivial subquery
+ * scans, and single-child Append and MergeAppend nodes.
+ */
+typedef struct ElidedNode
+{
+	NodeTag		type;
+	int			plan_node_id;
+	NodeTag		elided_type;
+	Bitmapset  *relids;
+} ElidedNode;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 40d02913080..53a70c210a7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -701,6 +701,7 @@ EachState
 Edge
 EditableObjectType
 ElementsState
+ElidedNode
 EnableTimeoutParams
 EndDataPtrType
 EndDirectModify_function
-- 
2.51.0



  [application/octet-stream] v5-0003-Store-information-about-Append-node-consolidation.patch (27.0K, 4-v5-0003-Store-information-about-Append-node-consolidation.patch)
  download | inline diff:
From ca17cf439732b2105e532853f1c9eda9a42e540f Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:07 -0400
Subject: [PATCH v5 3/6] Store information about Append node consolidation in
 the final plan.

An extension (or core code) might want to reconstruct the planner's
decisions about whether and where to perform partitionwise joins from
the final plan. To do so, it must be possible to find all of the RTIs
of partitioned tables appearing in the plan. But when an AppendPath
or MergeAppendPath pulls up child paths from a subordinate AppendPath
or MergeAppendPath, the RTIs of the subordinate path do not appear
in the final plan, making this kind of reconstruction impossible.

To avoid this, propagate the RTI sets that would have been present
in the 'apprelids' field of the subordinate Append or MergeAppend
nodes that would have been created into the surviving Append or
MergeAppend node, using a new 'child_append_relid_sets' field for
that purpose. The value of this field is a list of Bitmapsets,
because each relation whose append-list was pulled up had its own
set of RTIs: just one, if it was a partitionwise scan, or more than
one, if it was a partitionwise join. Since our goal is to see where
partitionwise joins were done, it is essential to avoid losing the
information about how the RTIs were grouped in the pulled-up
relations.

This commit also updates pg_overexplain so that EXPLAIN (RANGE_TABLE)
will display the saved RTI sets.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 56 +++++++++++
 src/backend/optimizer/path/allpaths.c         | 98 +++++++++++++++----
 src/backend/optimizer/path/joinrels.c         |  2 +-
 src/backend/optimizer/plan/createplan.c       |  2 +
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/prep/prepunion.c        | 11 ++-
 src/backend/optimizer/util/pathnode.c         |  5 +
 src/include/nodes/pathnodes.h                 | 10 ++
 src/include/nodes/plannodes.h                 | 11 +++
 src/include/optimizer/pathnode.h              |  2 +
 11 files changed, 175 insertions(+), 27 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index ca9a23ea61f..a377fb2571d 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -104,6 +104,7 @@ $$);
                Parallel Safe: true
                Plan Node ID: 2
                Append RTIs: 1
+               Child Append RTIs: none
                ->  Seq Scan on brassica vegetables_1
                      Disabled Nodes: 0
                      Parallel Safe: true
@@ -142,7 +143,7 @@ $$);
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 3 4
-(53 rows)
+(54 rows)
 
 -- Test a different output format.
 SELECT explain_filter($$
@@ -197,6 +198,7 @@ $$);
                <extParam>none</extParam>                            +
                <allParam>none</allParam>                            +
                <Append-RTIs>1</Append-RTIs>                         +
+               <Child-Append-RTIs>none</Child-Append-RTIs>          +
                <Subplans-Removed>0</Subplans-Removed>               +
                <Plans>                                              +
                  <Plan>                                             +
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index e54f8cfc332..c28348ea966 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -54,6 +54,8 @@ static void overexplain_alias(const char *qlabel, Alias *alias,
 							  ExplainState *es);
 static void overexplain_bitmapset(const char *qlabel, Bitmapset *bms,
 								  ExplainState *es);
+static void overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+									   ExplainState *es);
 static void overexplain_intlist(const char *qlabel, List *list,
 								ExplainState *es);
 
@@ -232,11 +234,17 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 				overexplain_bitmapset("Append RTIs",
 									  ((Append *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((Append *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_MergeAppend:
 				overexplain_bitmapset("Append RTIs",
 									  ((MergeAppend *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((MergeAppend *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_Result:
 
@@ -815,6 +823,54 @@ overexplain_bitmapset(const char *qlabel, Bitmapset *bms, ExplainState *es)
 	pfree(buf.data);
 }
 
+/*
+ * Emit a text property describing the contents of a list of bitmapsets.
+ * If a bitmapset contains exactly 1 member, we just print an integer;
+ * otherwise, we surround the list of members by parentheses.
+ *
+ * If there are no bitmapsets in the list, we print the word "none".
+ */
+static void
+overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+						   ExplainState *es)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+
+	foreach_node(Bitmapset, bms, bms_list)
+	{
+		if (bms_membership(bms) == BMS_SINGLETON)
+			appendStringInfo(&buf, " %d", bms_singleton_member(bms));
+		else
+		{
+			int			x = -1;
+			bool		first = true;
+
+			appendStringInfoString(&buf, " (");
+			while ((x = bms_next_member(bms, x)) >= 0)
+			{
+				if (first)
+					first = false;
+				else
+					appendStringInfoChar(&buf, ' ');
+				appendStringInfo(&buf, "%d", x);
+			}
+			appendStringInfoChar(&buf, ')');
+		}
+	}
+
+	if (buf.len == 0)
+	{
+		ExplainPropertyText(qlabel, "none", es);
+		return;
+	}
+
+	Assert(buf.data[0] == ' ');
+	ExplainPropertyText(qlabel, buf.data + 1, es);
+	pfree(buf.data);
+}
+
 /*
  * Emit a text property describing the contents of a list of integers, OIDs,
  * or XIDs -- either a space-separated list of integer members, or the word
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 4c43fd0b19b..928b8d84ad8 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -128,8 +128,10 @@ static Path *get_cheapest_parameterized_child_path(PlannerInfo *root,
 												   Relids required_outer);
 static void accumulate_append_subpath(Path *path,
 									  List **subpaths,
-									  List **special_subpaths);
-static Path *get_singleton_append_subpath(Path *path);
+									  List **special_subpaths,
+									  List **child_append_relid_sets);
+static Path *get_singleton_append_subpath(Path *path,
+										  List **child_append_relid_sets);
 static void set_dummy_rel_pathlist(RelOptInfo *rel);
 static void set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 								  Index rti, RangeTblEntry *rte);
@@ -1406,11 +1408,15 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 {
 	List	   *subpaths = NIL;
 	bool		subpaths_valid = true;
+	List	   *subpath_cars = NIL;
 	List	   *startup_subpaths = NIL;
 	bool		startup_subpaths_valid = true;
+	List	   *startup_subpath_cars = NIL;
 	List	   *partial_subpaths = NIL;
+	List	   *partial_subpath_cars = NIL;
 	List	   *pa_partial_subpaths = NIL;
 	List	   *pa_nonpartial_subpaths = NIL;
+	List	   *pa_subpath_cars = NIL;
 	bool		partial_subpaths_valid = true;
 	bool		pa_subpaths_valid;
 	List	   *all_child_pathkeys = NIL;
@@ -1443,7 +1449,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		if (childrel->pathlist != NIL &&
 			childrel->cheapest_total_path->param_info == NULL)
 			accumulate_append_subpath(childrel->cheapest_total_path,
-									  &subpaths, NULL);
+									  &subpaths, NULL, &subpath_cars);
 		else
 			subpaths_valid = false;
 
@@ -1472,7 +1478,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 			Assert(cheapest_path->param_info == NULL);
 			accumulate_append_subpath(cheapest_path,
 									  &startup_subpaths,
-									  NULL);
+									  NULL,
+									  &startup_subpath_cars);
 		}
 		else
 			startup_subpaths_valid = false;
@@ -1483,7 +1490,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		{
 			cheapest_partial_path = linitial(childrel->partial_pathlist);
 			accumulate_append_subpath(cheapest_partial_path,
-									  &partial_subpaths, NULL);
+									  &partial_subpaths, NULL,
+									  &partial_subpath_cars);
 		}
 		else
 			partial_subpaths_valid = false;
@@ -1512,7 +1520,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				Assert(cheapest_partial_path != NULL);
 				accumulate_append_subpath(cheapest_partial_path,
 										  &pa_partial_subpaths,
-										  &pa_nonpartial_subpaths);
+										  &pa_nonpartial_subpaths,
+										  &pa_subpath_cars);
 			}
 			else
 			{
@@ -1531,7 +1540,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				 */
 				accumulate_append_subpath(nppath,
 										  &pa_nonpartial_subpaths,
-										  NULL);
+										  NULL,
+										  &pa_subpath_cars);
 			}
 		}
 
@@ -1606,14 +1616,16 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 	 * if we have zero or one live subpath due to constraint exclusion.)
 	 */
 	if (subpaths_valid)
-		add_path(rel, (Path *) create_append_path(root, rel, subpaths, NIL,
+		add_path(rel, (Path *) create_append_path(root, rel, subpaths,
+												  NIL, subpath_cars,
 												  NIL, NULL, 0, false,
 												  -1));
 
 	/* build an AppendPath for the cheap startup paths, if valid */
 	if (startup_subpaths_valid)
 		add_path(rel, (Path *) create_append_path(root, rel, startup_subpaths,
-												  NIL, NIL, NULL, 0, false, -1));
+												  NIL, startup_subpath_cars,
+												  NIL, NULL, 0, false, -1));
 
 	/*
 	 * Consider an append of unordered, unparameterized partial paths.  Make
@@ -1654,6 +1666,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Generate a partial append path. */
 		appendpath = create_append_path(root, rel, NIL, partial_subpaths,
+										partial_subpath_cars,
 										NIL, NULL, parallel_workers,
 										enable_parallel_append,
 										-1);
@@ -1704,6 +1717,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		appendpath = create_append_path(root, rel, pa_nonpartial_subpaths,
 										pa_partial_subpaths,
+										pa_subpath_cars,
 										NIL, NULL, parallel_workers, true,
 										partial_rows);
 		add_partial_path(rel, (Path *) appendpath);
@@ -1737,6 +1751,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Select the child paths for an Append with this parameterization */
 		subpaths = NIL;
+		subpath_cars = NIL;
 		subpaths_valid = true;
 		foreach(lcr, live_childrels)
 		{
@@ -1759,12 +1774,13 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				subpaths_valid = false;
 				break;
 			}
-			accumulate_append_subpath(subpath, &subpaths, NULL);
+			accumulate_append_subpath(subpath, &subpaths, NULL,
+									  &subpath_cars);
 		}
 
 		if (subpaths_valid)
 			add_path(rel, (Path *)
-					 create_append_path(root, rel, subpaths, NIL,
+					 create_append_path(root, rel, subpaths, NIL, subpath_cars,
 										NIL, required_outer, 0, false,
 										-1));
 	}
@@ -1791,6 +1807,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				continue;
 
 			appendpath = create_append_path(root, rel, NIL, list_make1(path),
+											list_make1(rel->relids),
 											NIL, NULL,
 											path->parallel_workers, true,
 											partial_rows);
@@ -1874,8 +1891,11 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 	{
 		List	   *pathkeys = (List *) lfirst(lcp);
 		List	   *startup_subpaths = NIL;
+		List	   *startup_subpath_cars = NIL;
 		List	   *total_subpaths = NIL;
+		List	   *total_subpath_cars = NIL;
 		List	   *fractional_subpaths = NIL;
+		List	   *fractional_subpath_cars = NIL;
 		bool		startup_neq_total = false;
 		bool		fraction_neq_total = false;
 		bool		match_partition_order;
@@ -2038,16 +2058,23 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * just a single subpath (and hence aren't doing anything
 				 * useful).
 				 */
-				cheapest_startup = get_singleton_append_subpath(cheapest_startup);
-				cheapest_total = get_singleton_append_subpath(cheapest_total);
+				cheapest_startup =
+					get_singleton_append_subpath(cheapest_startup,
+												 &startup_subpath_cars);
+				cheapest_total =
+					get_singleton_append_subpath(cheapest_total,
+												 &total_subpath_cars);
 
 				startup_subpaths = lappend(startup_subpaths, cheapest_startup);
 				total_subpaths = lappend(total_subpaths, cheapest_total);
 
 				if (cheapest_fractional)
 				{
-					cheapest_fractional = get_singleton_append_subpath(cheapest_fractional);
-					fractional_subpaths = lappend(fractional_subpaths, cheapest_fractional);
+					cheapest_fractional =
+						get_singleton_append_subpath(cheapest_fractional,
+													 &fractional_subpath_cars);
+					fractional_subpaths =
+						lappend(fractional_subpaths, cheapest_fractional);
 				}
 			}
 			else
@@ -2057,13 +2084,16 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * child paths for the MergeAppend.
 				 */
 				accumulate_append_subpath(cheapest_startup,
-										  &startup_subpaths, NULL);
+										  &startup_subpaths, NULL,
+										  &startup_subpath_cars);
 				accumulate_append_subpath(cheapest_total,
-										  &total_subpaths, NULL);
+										  &total_subpaths, NULL,
+										  &total_subpath_cars);
 
 				if (cheapest_fractional)
 					accumulate_append_subpath(cheapest_fractional,
-											  &fractional_subpaths, NULL);
+											  &fractional_subpaths, NULL,
+											  &fractional_subpath_cars);
 			}
 		}
 
@@ -2075,6 +2105,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 													  rel,
 													  startup_subpaths,
 													  NIL,
+													  startup_subpath_cars,
 													  pathkeys,
 													  NULL,
 													  0,
@@ -2085,6 +2116,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  total_subpaths,
 														  NIL,
+														  total_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2096,6 +2128,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  fractional_subpaths,
 														  NIL,
+														  fractional_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2108,12 +2141,14 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 			add_path(rel, (Path *) create_merge_append_path(root,
 															rel,
 															startup_subpaths,
+															startup_subpath_cars,
 															pathkeys,
 															NULL));
 			if (startup_neq_total)
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																total_subpaths,
+																total_subpath_cars,
 																pathkeys,
 																NULL));
 
@@ -2121,6 +2156,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																fractional_subpaths,
+																fractional_subpath_cars,
 																pathkeys,
 																NULL));
 		}
@@ -2223,7 +2259,8 @@ get_cheapest_parameterized_child_path(PlannerInfo *root, RelOptInfo *rel,
  * paths).
  */
 static void
-accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
+accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
+						  List **child_append_relid_sets)
 {
 	if (IsA(path, AppendPath))
 	{
@@ -2232,6 +2269,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		if (!apath->path.parallel_aware || apath->first_partial_path == 0)
 		{
 			*subpaths = list_concat(*subpaths, apath->subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 		else if (special_subpaths != NULL)
@@ -2246,6 +2285,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 												  apath->first_partial_path);
 			*special_subpaths = list_concat(*special_subpaths,
 											new_special_subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 	}
@@ -2254,6 +2295,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		*subpaths = list_concat(*subpaths, mpath->subpaths);
+		*child_append_relid_sets =
+			lappend(*child_append_relid_sets, path->parent->relids);
 		return;
 	}
 
@@ -2265,10 +2308,15 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
  *		Returns the single subpath of an Append/MergeAppend, or just
  *		return 'path' if it's not a single sub-path Append/MergeAppend.
  *
+ * As a side effect, whenever we return a single subpath rather than the
+ * original path, add the relid set for the original path to
+ * child_append_relid_sets, so that those relids don't entirely disappear
+ * from the final plan.
+ *
  * Note: 'path' must not be a parallel-aware path.
  */
 static Path *
-get_singleton_append_subpath(Path *path)
+get_singleton_append_subpath(Path *path, List **child_append_relid_sets)
 {
 	Assert(!path->parallel_aware);
 
@@ -2277,14 +2325,22 @@ get_singleton_append_subpath(Path *path)
 		AppendPath *apath = (AppendPath *) path;
 
 		if (list_length(apath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(apath->subpaths);
+		}
 	}
 	else if (IsA(path, MergeAppendPath))
 	{
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		if (list_length(mpath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(mpath->subpaths);
+		}
 	}
 
 	return path;
@@ -2313,7 +2369,7 @@ set_dummy_rel_pathlist(RelOptInfo *rel)
 	rel->partial_pathlist = NIL;
 
 	/* Set up the dummy path */
-	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
+	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL, NIL,
 											  NIL, rel->lateral_relids,
 											  0, false, -1));
 
diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 5d1fc3899da..c1ed0d3870f 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -1530,7 +1530,7 @@ mark_dummy_rel(RelOptInfo *rel)
 
 	/* Set up the dummy path */
 	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
-											  NIL, rel->lateral_relids,
+											  NIL, NIL, rel->lateral_relids,
 											  0, false, -1));
 
 	/* Set or update cheapest_total_path and related fields */
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 8af091ba647..88b4c5901b0 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -1265,6 +1265,7 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags)
 	plan->plan.lefttree = NULL;
 	plan->plan.righttree = NULL;
 	plan->apprelids = rel->relids;
+	plan->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	if (pathkeys != NIL)
 	{
@@ -1477,6 +1478,7 @@ create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path,
 	plan->lefttree = NULL;
 	plan->righttree = NULL;
 	node->apprelids = rel->relids;
+	node->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	/*
 	 * Compute sort column info, and adjust MergeAppend's tlist as needed.
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 2755ef00e90..baf0d9420a2 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4027,6 +4027,7 @@ create_degenerate_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
 							   paths,
 							   NIL,
 							   NIL,
+							   NIL,
 							   NULL,
 							   0,
 							   false,
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index f528f096a56..ca2258e44d1 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -843,7 +843,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 	 * union child.
 	 */
 	apath = (Path *) create_append_path(root, result_rel, cheapest_pathlist,
-										NIL, NIL, NULL, 0, false, -1);
+										NIL, NIL, NIL, NULL, 0, false, -1);
 
 	/*
 	 * Estimate number of groups.  For now we just assume the output is unique
@@ -889,7 +889,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 
 		papath = (Path *)
 			create_append_path(root, result_rel, NIL, partial_pathlist,
-							   NIL, NULL, parallel_workers,
+							   NIL, NIL, NULL, parallel_workers,
 							   enable_parallel_append, -1);
 		gpath = (Path *)
 			create_gather_path(root, result_rel, papath,
@@ -1018,6 +1018,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 			path = (Path *) create_merge_append_path(root,
 													 result_rel,
 													 ordered_pathlist,
+													 NIL,
 													 union_pathkeys,
 													 NULL);
 
@@ -1224,8 +1225,10 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root,
 				 * between the set op targetlist and the targetlist of the
 				 * left input.  The Append will be removed in setrefs.c.
 				 */
-				apath = (Path *) create_append_path(root, result_rel, list_make1(lpath),
-													NIL, NIL, NULL, 0, false, -1);
+				apath = (Path *) create_append_path(root, result_rel,
+													list_make1(lpath),
+													NIL, NIL, NIL, NULL, 0,
+													false, -1);
 
 				add_path(result_rel, apath);
 
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index b6be4ddbd01..33ce34f0088 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1301,6 +1301,7 @@ AppendPath *
 create_append_path(PlannerInfo *root,
 				   RelOptInfo *rel,
 				   List *subpaths, List *partial_subpaths,
+				   List *child_append_relid_sets,
 				   List *pathkeys, Relids required_outer,
 				   int parallel_workers, bool parallel_aware,
 				   double rows)
@@ -1310,6 +1311,7 @@ create_append_path(PlannerInfo *root,
 
 	Assert(!parallel_aware || parallel_workers > 0);
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_Append;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -1472,6 +1474,7 @@ MergeAppendPath *
 create_merge_append_path(PlannerInfo *root,
 						 RelOptInfo *rel,
 						 List *subpaths,
+						 List *child_append_relid_sets,
 						 List *pathkeys,
 						 Relids required_outer)
 {
@@ -1487,6 +1490,7 @@ create_merge_append_path(PlannerInfo *root,
 	 */
 	Assert(bms_is_empty(rel->lateral_relids) && bms_is_empty(required_outer));
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_MergeAppend;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -3951,6 +3955,7 @@ reparameterize_path(PlannerInfo *root, Path *path,
 				}
 				return (Path *)
 					create_append_path(root, rel, childpaths, partialpaths,
+									   apath->child_append_relid_sets,
 									   apath->path.pathkeys, required_outer,
 									   apath->path.parallel_workers,
 									   apath->path.parallel_aware,
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 42c146d802a..e168b17cd28 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2172,6 +2172,12 @@ typedef struct CustomPath
  * For partial Append, 'subpaths' contains non-partial subpaths followed by
  * partial subpaths.
  *
+ * Whenever accumulate_append_subpath() allows us to consolidate multiple
+ * levels of Append paths are consolidated down to one, we store the RTI
+ * sets for the omitted paths in child_append_relid_sets. This is not necessary
+ * for planning or execution; we do it for the benefit of code that wants
+ * to inspect the final plan and understand how it came to be.
+ *
  * Note: it is possible for "subpaths" to contain only one, or even no,
  * elements.  These cases are optimized during create_append_plan.
  * In particular, an AppendPath with no subpaths is a "dummy" path that
@@ -2187,6 +2193,7 @@ typedef struct AppendPath
 	/* Index of first partial path in subpaths; list_length(subpaths) if none */
 	int			first_partial_path;
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } AppendPath;
 
 #define IS_DUMMY_APPEND(p) \
@@ -2203,12 +2210,15 @@ extern bool is_dummy_rel(RelOptInfo *rel);
 /*
  * MergeAppendPath represents a MergeAppend plan, ie, the merging of sorted
  * results from several member plans to produce similarly-sorted output.
+ *
+ * child_append_relid_sets has the same meaning here as for AppendPath.
  */
 typedef struct MergeAppendPath
 {
 	Path		path;
 	List	   *subpaths;		/* list of component Paths */
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } MergeAppendPath;
 
 /*
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 5d0520d5e58..045b7ee84a7 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -394,9 +394,16 @@ struct PartitionPruneInfo;		/* forward reference to struct below */
 typedef struct Append
 {
 	Plan		plan;
+
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
+
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *appendplans;
+
 	/* # of asynchronous plans */
 	int			nasyncplans;
 
@@ -426,6 +433,10 @@ typedef struct MergeAppend
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
 
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *mergeplans;
 
 	/* these fields are just like the sort-key info in struct Sort: */
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 6b010f0b1a5..dbf4702acc9 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -71,12 +71,14 @@ extern TidRangePath *create_tidrangescan_path(PlannerInfo *root,
 											  int parallel_workers);
 extern AppendPath *create_append_path(PlannerInfo *root, RelOptInfo *rel,
 									  List *subpaths, List *partial_subpaths,
+									  List *child_append_relid_sets,
 									  List *pathkeys, Relids required_outer,
 									  int parallel_workers, bool parallel_aware,
 									  double rows);
 extern MergeAppendPath *create_merge_append_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 List *subpaths,
+												 List *child_append_relid_sets,
 												 List *pathkeys,
 												 Relids required_outer);
 extern GroupResultPath *create_group_result_path(PlannerInfo *root,
-- 
2.51.0



  [application/octet-stream] v5-0004-Allow-for-plugin-control-over-path-generation-str.patch (55.7K, 5-v5-0004-Allow-for-plugin-control-over-path-generation-str.patch)
  download | inline diff:
From 6313681b18678f0313986532d9f0c1fd996b3dac Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 24 Oct 2025 15:11:47 -0400
Subject: [PATCH v5 4/6] Allow for plugin control over path generation
 strategies.

Each RelOptInfo now has a pgs_mask member which is a mask of acceptable
strategies. For most rels, this is populated from PlannerGlobal's
default_pgs_mask, which is computed from the values of the enable_*
GUCs at the start of planning.

For baserels, get_relation_info_hook can be used to adjust pgs_mask for
each new RelOptInfo, at least for rels of type RTE_RELATION. Adjusting
pgs_mask is less useful for other types of rels, but if it proves to
be necessary, we can revisit the way this hook works or add a new one.

For joinrels, two new hooks are added. joinrel_setup_hook is called each
time a joinrel is created, and one thing that can be done from that hook
is to manipulate pgs_mask for the new joinrel. join_path_setup_hook is
called each time we're about to add paths to a joinrel by considering
some particular combination of an outer rel, an inner rel, and a join
type. It can modify the pgs_mask propagated into JoinPathExtraData to
restrict strategy choice for that paricular combination of rels.

To make joinrel_setup_hook work as intended, the existing calls to
build_joinrel_partition_info are moved later in the calling functions;
this is because that function checks whether the rel's pgs_mask includes
PGS_CONSIDER_PARTITIONWISE, so we want it to only be called after
plugins have had a chance to alter pgs_mask.

Upper rels currently inherit pgs_mask from the input relation. It's
unclear that this is the most useful behavior, but at the moment there
are no hooks to allow the mask to be set in any other way.
---
 src/backend/optimizer/path/allpaths.c   |   2 +-
 src/backend/optimizer/path/costsize.c   | 222 ++++++++++++++++++------
 src/backend/optimizer/path/indxpath.c   |   4 +-
 src/backend/optimizer/path/joinpath.c   |  88 +++++++---
 src/backend/optimizer/path/tidpath.c    |   7 +-
 src/backend/optimizer/plan/createplan.c |   4 +-
 src/backend/optimizer/plan/planner.c    |  54 ++++++
 src/backend/optimizer/util/pathnode.c   |  19 +-
 src/backend/optimizer/util/plancat.c    |   3 +
 src/backend/optimizer/util/relnode.c    |  43 ++++-
 src/include/nodes/pathnodes.h           |  82 ++++++++-
 src/include/optimizer/cost.h            |   4 +-
 src/include/optimizer/pathnode.h        |  11 +-
 src/include/optimizer/paths.h           |   9 +-
 14 files changed, 454 insertions(+), 98 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 928b8d84ad8..8e9dde3d195 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -954,7 +954,7 @@ set_tablesample_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *
 		 bms_membership(root->all_query_rels) != BMS_SINGLETON) &&
 		!(GetTsmRoutine(rte->tablesample->tsmhandler)->repeatable_across_scans))
 	{
-		path = (Path *) create_material_path(rel, path);
+		path = (Path *) create_material_path(rel, path, true);
 	}
 
 	add_path(rel, path);
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 5a7283bd2f5..5b0240f60d3 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -275,6 +275,7 @@ cost_seqscan(Path *path, PlannerInfo *root,
 	double		spc_seq_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = PGS_SEQSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -327,8 +328,11 @@ cost_seqscan(Path *path, PlannerInfo *root,
 		 */
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	path->disabled_nodes = enable_seqscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
 }
@@ -354,6 +358,7 @@ cost_samplescan(Path *path, PlannerInfo *root,
 				spc_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations with tablesample clauses */
 	Assert(baserel->relid > 0);
@@ -401,7 +406,11 @@ cost_samplescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -440,7 +449,8 @@ cost_gather(GatherPath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows;
 
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost;
 	path->path.total_cost = (startup_cost + run_cost);
 }
@@ -506,8 +516,8 @@ cost_gather_merge(GatherMergePath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows * 1.05;
 
-	path->path.disabled_nodes = input_disabled_nodes
-		+ (enable_gathermerge ? 0 : 1);
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER_MERGE) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost + input_startup_cost;
 	path->path.total_cost = (startup_cost + run_cost + input_total_cost);
 }
@@ -557,6 +567,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	double		pages_fetched;
 	double		rand_heap_pages;
 	double		index_pages;
+	uint64		enable_mask;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo) &&
@@ -588,8 +599,11 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 											  path->indexclauses);
 	}
 
-	/* we don't need to check enable_indexonlyscan; indxpath.c does that */
-	path->path.disabled_nodes = enable_indexscan ? 0 : 1;
+	/* is this scan type disabled? */
+	enable_mask = (indexonly ? PGS_INDEXONLYSCAN : PGS_INDEXSCAN)
+		| (path->path.parallel_workers == 0 ? PGS_CONSIDER_NONPARTIAL : 0);
+	path->path.disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 
 	/*
 	 * Call index-access-method-specific code to estimate the processing cost
@@ -1010,6 +1024,7 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	double		spc_seq_page_cost,
 				spc_random_page_cost;
 	double		T;
+	uint64		enable_mask = PGS_BITMAPSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo));
@@ -1075,6 +1090,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 
 	run_cost += cpu_run_cost;
@@ -1083,7 +1100,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = enable_bitmapscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1240,6 +1258,7 @@ cost_tidscan(Path *path, PlannerInfo *root,
 	double		ntuples;
 	ListCell   *l;
 	double		spc_random_page_cost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1261,10 +1280,10 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 		/*
 		 * We must use a TID scan for CurrentOfExpr; in any other case, we
-		 * should be generating a TID scan only if enable_tidscan=true. Also,
-		 * if CurrentOfExpr is the qual, there should be only one.
+		 * should be generating a TID scan only if TID scans are allowed.
+		 * Also, if CurrentOfExpr is the qual, there should be only one.
 		 */
-		Assert(enable_tidscan || IsA(qual, CurrentOfExpr));
+		Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0 || IsA(qual, CurrentOfExpr));
 		Assert(list_length(tidquals) == 1 || !IsA(qual, CurrentOfExpr));
 
 		if (IsA(qual, ScalarArrayOpExpr))
@@ -1316,10 +1335,14 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 	/*
 	 * There are assertions above verifying that we only reach this function
-	 * either when enable_tidscan=true or when the TID scan is the only legal
-	 * path, so it's safe to set disabled_nodes to zero here.
+	 * either when baserel->pgs_mask includes PGS_TIDSCAN or when the TID scan
+	 * is the only legal path, so we only need to consider the effects of
+	 * PGS_CONSIDER_NONPARTIAL here.
 	 */
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1350,6 +1373,7 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	double		nseqpages;
 	double		spc_random_page_cost;
 	double		spc_seq_page_cost;
+	uint64		enable_mask = PGS_TIDSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1428,8 +1452,15 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
 
-	/* we should not generate this path type when enable_tidscan=false */
-	Assert(enable_tidscan);
+	/*
+	 * We should not generate this path type when PGS_TIDSCAN is unset, but we
+	 * might need to disable this path due to PGS_CONSIDER_NONPARTIAL.
+	 */
+	Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0);
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->disabled_nodes = 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
@@ -1453,6 +1484,7 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	List	   *qpquals;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are subqueries */
 	Assert(baserel->relid > 0);
@@ -1483,7 +1515,10 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	 * SubqueryScan node, plus cpu_tuple_cost to account for selection and
 	 * projection overhead.
 	 */
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	if (path->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ (((baserel->pgs_mask & enable_mask) != enable_mask) ? 1 : 0);
 	path->path.startup_cost = path->subpath->startup_cost;
 	path->path.total_cost = path->subpath->total_cost;
 
@@ -1534,6 +1569,7 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1574,7 +1610,10 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1596,6 +1635,7 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1631,7 +1671,10 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1651,6 +1694,7 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are values lists */
 	Assert(baserel->relid > 0);
@@ -1679,7 +1723,10 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1702,6 +1749,7 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are CTEs */
 	Assert(baserel->relid > 0);
@@ -1727,7 +1775,10 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1744,6 +1795,7 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are Tuplestores */
 	Assert(baserel->relid > 0);
@@ -1765,7 +1817,10 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	cpu_per_tuple += cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1782,6 +1837,7 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to RTE_RESULT base relations */
 	Assert(baserel->relid > 0);
@@ -1800,7 +1856,10 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1818,6 +1877,7 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	Cost		startup_cost;
 	Cost		total_cost;
 	double		total_rows;
+	uint64		enable_mask = 0;
 
 	/* We probably have decent estimates for the non-recursive term */
 	startup_cost = nrterm->startup_cost;
@@ -1840,7 +1900,10 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	 */
 	total_cost += cpu_tuple_cost * total_rows;
 
-	runion->disabled_nodes = nrterm->disabled_nodes + rterm->disabled_nodes;
+	if (runion->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	runion->disabled_nodes =
+		(runion->parent->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	runion->startup_cost = startup_cost;
 	runion->total_cost = total_cost;
 	runion->rows = total_rows;
@@ -2110,7 +2173,11 @@ cost_incremental_sort(Path *path,
 
 	path->rows = input_tuples;
 
-	/* should not generate these paths when enable_incremental_sort=false */
+	/*
+	 * We should not generate these paths when enable_incremental_sort=false.
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	Assert(enable_incremental_sort);
 	path->disabled_nodes = input_disabled_nodes;
 
@@ -2148,6 +2215,10 @@ cost_sort(Path *path, PlannerInfo *root,
 
 	startup_cost += input_cost;
 
+	/*
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	path->rows = tuples;
 	path->disabled_nodes = input_disabled_nodes + (enable_sort ? 0 : 1);
 	path->startup_cost = startup_cost;
@@ -2239,9 +2310,15 @@ append_nonpartial_cost(List *subpaths, int numpaths, int parallel_workers)
 void
 cost_append(AppendPath *apath, PlannerInfo *root)
 {
+	RelOptInfo *rel = apath->path.parent;
 	ListCell   *l;
+	uint64		enable_mask = PGS_APPEND;
+
+	if (apath->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	apath->path.disabled_nodes = 0;
+	apath->path.disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	apath->path.startup_cost = 0;
 	apath->path.total_cost = 0;
 	apath->path.rows = 0;
@@ -2451,11 +2528,16 @@ cost_merge_append(Path *path, PlannerInfo *root,
 				  Cost input_startup_cost, Cost input_total_cost,
 				  double tuples)
 {
+	RelOptInfo *rel = path->parent;
 	Cost		startup_cost = 0;
 	Cost		run_cost = 0;
 	Cost		comparison_cost;
 	double		N;
 	double		logN;
+	uint64		enable_mask = PGS_MERGE_APPEND;
+
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/*
 	 * Avoid log(0)...
@@ -2478,7 +2560,9 @@ cost_merge_append(Path *path, PlannerInfo *root,
 	 */
 	run_cost += cpu_tuple_cost * APPEND_CPU_COST_MULTIPLIER * tuples;
 
-	path->disabled_nodes = input_disabled_nodes;
+	path->disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
+	path->disabled_nodes += input_disabled_nodes;
 	path->startup_cost = startup_cost + input_startup_cost;
 	path->total_cost = startup_cost + run_cost + input_total_cost;
 }
@@ -2497,7 +2581,7 @@ cost_merge_append(Path *path, PlannerInfo *root,
  */
 void
 cost_material(Path *path,
-			  int input_disabled_nodes,
+			  bool enabled, int input_disabled_nodes,
 			  Cost input_startup_cost, Cost input_total_cost,
 			  double tuples, int width)
 {
@@ -2506,6 +2590,11 @@ cost_material(Path *path,
 	double		nbytes = relation_byte_size(tuples, width);
 	double		work_mem_bytes = work_mem * (Size) 1024;
 
+	if (path->parallel_workers == 0 &&
+		path->parent != NULL &&
+		(path->parent->pgs_mask & PGS_CONSIDER_NONPARTIAL) == 0)
+		enabled = false;
+
 	path->rows = tuples;
 
 	/*
@@ -2535,7 +2624,7 @@ cost_material(Path *path,
 		run_cost += seq_page_cost * npages;
 	}
 
-	path->disabled_nodes = input_disabled_nodes + (enable_material ? 0 : 1);
+	path->disabled_nodes = input_disabled_nodes + (enabled ? 0 : 1);
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -3287,7 +3376,7 @@ cost_group(Path *path, PlannerInfo *root,
  */
 void
 initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
-					  JoinType jointype,
+					  JoinType jointype, uint64 enable_mask,
 					  Path *outer_path, Path *inner_path,
 					  JoinPathExtraData *extra)
 {
@@ -3301,7 +3390,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Cost		inner_rescan_run_cost;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_nestloop ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
@@ -3701,7 +3790,19 @@ initial_cost_mergejoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Assert(outerstartsel <= outerendsel);
 	Assert(innerstartsel <= innerendsel);
 
-	disabled_nodes = enable_mergejoin ? 0 : 1;
+	/*
+	 * We don't decide whether to materialize the inner path until we get to
+	 * final_cost_mergejoin(), so we don't know whether to check the pgs_mask
+	 * again PGS_MERGEJOIN_PLAIN or PGS_MERGEJOIN_MATERIALIZE. Instead, we
+	 * just account for any child nodes here and assume that this node is not
+	 * itslef disabled; we can sort out the details in final_cost_mergejoin().
+	 *
+	 * (We could be more precise here by setting disabled_nodes to 1 at this
+	 * stage if both PGS_MERGEJOIN_PLAIN and PGS_MERGEJOIN_MATERIALIZE are
+	 * disabled, but that seems to against the idea of making this function
+	 * produce a quick, optimistic approximation of the final cost.)
+	 */
+	disabled_nodes = 0;
 
 	/* cost of source data */
 
@@ -3880,9 +3981,7 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	double		mergejointuples,
 				rescannedtuples;
 	double		rescanratio;
-
-	/* Set the number of disabled nodes. */
-	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+	uint64		enable_mask = 0;
 
 	/* Protect some assumptions below that rowcounts aren't zero */
 	if (inner_path_rows <= 0)
@@ -4012,16 +4111,20 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 		path->materialize_inner = false;
 
 	/*
-	 * Prefer materializing if it looks cheaper, unless the user has asked to
-	 * suppress materialization.
+	 * If merge joins with materialization are enabled, then choose
+	 * materialization if either (a) it looks cheaper or (b) merge joins
+	 * without materialization are disabled.
 	 */
-	else if (enable_material && mat_inner_cost < bare_inner_cost)
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 (mat_inner_cost < bare_inner_cost ||
+			  (extra->pgs_mask & PGS_MERGEJOIN_PLAIN) == 0))
 		path->materialize_inner = true;
 
 	/*
-	 * Even if materializing doesn't look cheaper, we *must* do it if the
-	 * inner path is to be used directly (without sorting) and it doesn't
-	 * support mark/restore.
+	 * Regardless of what plan shapes are enabled and what the costs seem to
+	 * be, we *must* materialize it if the inner path is to be used directly
+	 * (without sorting) and it doesn't support mark/restore. Planner failure
+	 * is not an option!
 	 *
 	 * Since the inner side must be ordered, and only Sorts and IndexScans can
 	 * create order to begin with, and they both support mark/restore, you
@@ -4029,10 +4132,6 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * merge joins can *preserve* the order of their inputs, so they can be
 	 * selected as the input of a mergejoin, and they don't support
 	 * mark/restore at present.
-	 *
-	 * We don't test the value of enable_material here, because
-	 * materialization is required for correctness in this case, and turning
-	 * it off does not entitle us to deliver an invalid plan.
 	 */
 	else if (innersortkeys == NIL &&
 			 !ExecSupportsMarkRestore(inner_path))
@@ -4046,10 +4145,11 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * though.
 	 *
 	 * Since materialization is a performance optimization in this case,
-	 * rather than necessary for correctness, we skip it if enable_material is
-	 * off.
+	 * rather than necessary for correctness, we skip it if materialization is
+	 * switched off.
 	 */
-	else if (enable_material && innersortkeys != NIL &&
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 innersortkeys != NIL &&
 			 relation_byte_size(inner_path_rows,
 								inner_path->pathtarget->width) >
 			 work_mem * (Size) 1024)
@@ -4057,11 +4157,29 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	else
 		path->materialize_inner = false;
 
-	/* Charge the right incremental cost for the chosen case */
+	/* Get the number of disabled nodes, not yet including this one. */
+	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+
+	/*
+	 * Charge the right incremental cost for the chosen case, and update
+	 * enable_mask as appropriate.
+	 */
 	if (path->materialize_inner)
+	{
 		run_cost += mat_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
 	else
+	{
 		run_cost += bare_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_PLAIN;
+	}
+
+	/* Incremental count of disabled nodes if this node is disabled. */
+	if (path->jpath.path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	if ((extra->pgs_mask & enable_mask) != enable_mask)
+		++path->jpath.path.disabled_nodes;
 
 	/* CPU costs */
 
@@ -4199,9 +4317,13 @@ initial_cost_hashjoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	int			numbatches;
 	int			num_skew_mcvs;
 	size_t		space_allowed;	/* unused */
+	uint64		enable_mask = PGS_HASHJOIN;
+
+	if (outer_path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_hashjoin ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index 2654c59c4c6..45998bbe829 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -2233,8 +2233,8 @@ check_index_only(RelOptInfo *rel, IndexOptInfo *index)
 	ListCell   *lc;
 	int			i;
 
-	/* Index-only scans must be enabled */
-	if (!enable_indexonlyscan)
+	/* If we're not allowed to consider index-only scans, give up now */
+	if ((rel->pgs_mask & PGS_CONSIDER_INDEXONLY) == 0)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index ea5b6415186..388d8456ff6 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -29,8 +29,9 @@
 #include "utils/lsyscache.h"
 #include "utils/typcache.h"
 
-/* Hook for plugins to get control in add_paths_to_joinrel() */
+/* Hooks for plugins to get control in add_paths_to_joinrel() */
 set_join_pathlist_hook_type set_join_pathlist_hook = NULL;
+join_path_setup_hook_type join_path_setup_hook = NULL;
 
 /*
  * Paths parameterized by a parent rel can be considered to be parameterized
@@ -151,6 +152,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	extra.mergeclause_list = NIL;
 	extra.sjinfo = sjinfo;
 	extra.param_source_rels = NULL;
+	extra.pgs_mask = joinrel->pgs_mask;
 
 	/*
 	 * See if the inner relation is provably unique for this outer rel.
@@ -207,13 +209,38 @@ add_paths_to_joinrel(PlannerInfo *root,
 	if (jointype == JOIN_UNIQUE_OUTER || jointype == JOIN_UNIQUE_INNER)
 		jointype = JOIN_INNER;
 
+	/*
+	 * Give extensions a chance to take control. In particular, an extension
+	 * might want to modify extra.pgs_mask. It's possible to override pgs_mask
+	 * on a query-wide basis using join_search_hook, or for a particular
+	 * relation using joinrel_setup_hook, but extensions that want to provide
+	 * different advice for the same joinrel based on the choice of innerrel
+	 * and outerrel will need to use this hook.
+	 *
+	 * A very simple way for an extension to use this hook is to set
+	 * extra.pgs_mask = 0, if it simply doesn't want any of the paths
+	 * generated by this call to add_paths_to_joinrel() to be selected. An
+	 * extension could use this technique to constrain the join order, since
+	 * it could thereby arrange to reject all paths from join orders that it
+	 * does not like. An extension can also selectively clear bits from
+	 * extra.pgs_mask to rule out specific techniques for specific joins, or
+	 * even replace the mask entirely.
+	 *
+	 * NB: Below this point, this function should be careful to reference
+	 * extra.pgs_mask rather than rel->pgs_mask to avoid disregarding any
+	 * changes made by the hook we're about to call.
+	 */
+	if (join_path_setup_hook)
+		join_path_setup_hook(root, joinrel, outerrel, innerrel,
+							 jointype, &extra);
+
 	/*
 	 * Find potential mergejoin clauses.  We can skip this if we are not
 	 * interested in doing a mergejoin.  However, mergejoin may be our only
-	 * way of implementing a full outer join, so override enable_mergejoin if
-	 * it's a full join.
+	 * way of implementing a full outer join, so in that case we don't care
+	 * whether mergejoins are disabled.
 	 */
-	if (enable_mergejoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_MERGEJOIN_ANY) != 0 || jointype == JOIN_FULL)
 		extra.mergeclause_list = select_mergejoin_clauses(root,
 														  joinrel,
 														  outerrel,
@@ -321,10 +348,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 
 	/*
 	 * 4. Consider paths where both outer and inner relations must be hashed
-	 * before being joined.  As above, disregard enable_hashjoin for full
-	 * joins, because there may be no other alternative.
+	 * before being joined.  As above, when it's a full join, we must try this
+	 * even when the path type is disabled, because it may be our only option.
 	 */
-	if (enable_hashjoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_HASHJOIN) != 0 || jointype == JOIN_FULL)
 		hash_inner_and_outer(root, joinrel, outerrel, innerrel,
 							 jointype, &extra);
 
@@ -333,7 +360,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	 * to the same server and assigned to the same user to check access
 	 * permissions as, give the FDW a chance to push down joins.
 	 */
-	if (joinrel->fdwroutine &&
+	if ((extra.pgs_mask & PGS_FOREIGNJOIN) != 0 && joinrel->fdwroutine &&
 		joinrel->fdwroutine->GetForeignJoinPaths)
 		joinrel->fdwroutine->GetForeignJoinPaths(root, joinrel,
 												 outerrel, innerrel,
@@ -342,8 +369,13 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * 6. Finally, give extensions a chance to manipulate the path list.  They
 	 * could add new paths (such as CustomPaths) by calling add_path(), or
-	 * add_partial_path() if parallel aware.  They could also delete or modify
-	 * paths added by the core code.
+	 * add_partial_path() if parallel aware.
+	 *
+	 * In theory, extensions could also use this hook to delete or modify
+	 * paths added by the core code, but in practice this is difficult to make
+	 * work, since it's too late to get back any paths that have already been
+	 * discarded by add_path() or add_partial_path(). If you're trying to
+	 * suppress paths, consider using join_path_setup_hook instead.
 	 */
 	if (set_join_pathlist_hook)
 		set_join_pathlist_hook(root, joinrel, outerrel, innerrel,
@@ -690,7 +722,7 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel,
 	List	   *ph_lateral_vars;
 
 	/* Obviously not if it's disabled */
-	if (!enable_memoize)
+	if ((extra->pgs_mask & PGS_NESTLOOP_MEMOIZE) == 0)
 		return NULL;
 
 	/*
@@ -845,6 +877,7 @@ try_nestloop_path(PlannerInfo *root,
 				  Path *inner_path,
 				  List *pathkeys,
 				  JoinType jointype,
+				  uint64 nestloop_subtype,
 				  JoinPathExtraData *extra)
 {
 	Relids		required_outer;
@@ -927,6 +960,7 @@ try_nestloop_path(PlannerInfo *root,
 	 * methodology worthwhile.
 	 */
 	initial_cost_nestloop(root, &workspace, jointype,
+						  nestloop_subtype | PGS_CONSIDER_NONPARTIAL,
 						  outer_path, inner_path, extra);
 
 	if (add_path_precheck(joinrel, workspace.disabled_nodes,
@@ -964,6 +998,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 						  Path *inner_path,
 						  List *pathkeys,
 						  JoinType jointype,
+						  uint64 nestloop_subtype,
 						  JoinPathExtraData *extra)
 {
 	JoinCostWorkspace workspace;
@@ -1011,7 +1046,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 	 * Before creating a path, get a quick lower bound on what it is likely to
 	 * cost.  Bail out right away if it looks terrible.
 	 */
-	initial_cost_nestloop(root, &workspace, jointype,
+	initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype,
 						  outer_path, inner_path, extra);
 	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
 								   workspace.total_cost, pathkeys))
@@ -1859,14 +1894,14 @@ match_unsorted_outer(PlannerInfo *root,
 	if (nestjoinOK)
 	{
 		/*
-		 * Consider materializing the cheapest inner path, unless
-		 * enable_material is off or the path in question materializes its
-		 * output anyway.
+		 * Consider materializing the cheapest inner path, unless that is
+		 * disabled or the path in question materializes its output anyway.
 		 */
-		if (enable_material && inner_cheapest_total != NULL &&
+		if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+			inner_cheapest_total != NULL &&
 			!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 			matpath = (Path *)
-				create_material_path(innerrel, inner_cheapest_total);
+				create_material_path(innerrel, inner_cheapest_total, true);
 	}
 
 	foreach(lc1, outerrel->pathlist)
@@ -1909,6 +1944,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  innerpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_PLAIN,
 								  extra);
 
 				/*
@@ -1925,6 +1961,7 @@ match_unsorted_outer(PlannerInfo *root,
 									  mpath,
 									  merge_pathkeys,
 									  jointype,
+									  PGS_NESTLOOP_MEMOIZE,
 									  extra);
 			}
 
@@ -1936,6 +1973,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  matpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_MATERIALIZE,
 								  extra);
 		}
 
@@ -2052,16 +2090,17 @@ consider_parallel_nestloop(PlannerInfo *root,
 
 	/*
 	 * Consider materializing the cheapest inner path, unless: 1)
-	 * enable_material is off, 2) the cheapest inner path is not
+	 * materialization is disabled here, 2) the cheapest inner path is not
 	 * parallel-safe, 3) the cheapest inner path is parameterized by the outer
 	 * rel, or 4) the cheapest inner path materializes its output anyway.
 	 */
-	if (enable_material && inner_cheapest_total->parallel_safe &&
+	if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+		inner_cheapest_total->parallel_safe &&
 		!PATH_PARAM_BY_REL(inner_cheapest_total, outerrel) &&
 		!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 	{
 		matpath = (Path *)
-			create_material_path(innerrel, inner_cheapest_total);
+			create_material_path(innerrel, inner_cheapest_total, true);
 		Assert(matpath->parallel_safe);
 	}
 
@@ -2091,7 +2130,8 @@ consider_parallel_nestloop(PlannerInfo *root,
 				continue;
 
 			try_partial_nestloop_path(root, joinrel, outerpath, innerpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_PLAIN, extra);
 
 			/*
 			 * Try generating a memoize path and see if that makes the nested
@@ -2102,13 +2142,15 @@ consider_parallel_nestloop(PlannerInfo *root,
 									 extra);
 			if (mpath != NULL)
 				try_partial_nestloop_path(root, joinrel, outerpath, mpath,
-										  pathkeys, jointype, extra);
+										  pathkeys, jointype,
+										  PGS_NESTLOOP_MEMOIZE, extra);
 		}
 
 		/* Also consider materialized form of the cheapest inner path */
 		if (matpath != NULL)
 			try_partial_nestloop_path(root, joinrel, outerpath, matpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_MATERIALIZE, extra);
 	}
 }
 
diff --git a/src/backend/optimizer/path/tidpath.c b/src/backend/optimizer/path/tidpath.c
index 3ddbc10bbdf..150115c293f 100644
--- a/src/backend/optimizer/path/tidpath.c
+++ b/src/backend/optimizer/path/tidpath.c
@@ -499,18 +499,19 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	List	   *tidquals;
 	List	   *tidrangequals;
 	bool		isCurrentOf;
+	bool		enabled = (rel->pgs_mask & PGS_TIDSCAN) != 0;
 
 	/*
 	 * If any suitable quals exist in the rel's baserestrict list, generate a
 	 * plain (unparameterized) TidPath with them.
 	 *
-	 * We skip this when enable_tidscan = false, except when the qual is
+	 * We skip this when TID scans are disabled, except when the qual is
 	 * CurrentOfExpr. In that case, a TID scan is the only correct path.
 	 */
 	tidquals = TidQualFromRestrictInfoList(root, rel->baserestrictinfo, rel,
 										   &isCurrentOf);
 
-	if (tidquals != NIL && (enable_tidscan || isCurrentOf))
+	if (tidquals != NIL && (enabled || isCurrentOf))
 	{
 		/*
 		 * This path uses no join clauses, but it could still have required
@@ -532,7 +533,7 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	}
 
 	/* Skip the rest if TID scans are disabled. */
-	if (!enable_tidscan)
+	if (!enabled)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 88b4c5901b0..0a2e688b231 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -6503,7 +6503,7 @@ Plan *
 materialize_finished_plan(Plan *subplan)
 {
 	Plan	   *matplan;
-	Path		matpath;		/* dummy for result of cost_material */
+	Path		matpath;		/* dummy for cost_material */
 	Cost		initplan_cost;
 	bool		unsafe_initplans;
 
@@ -6525,7 +6525,9 @@ materialize_finished_plan(Plan *subplan)
 	subplan->total_cost -= initplan_cost;
 
 	/* Set cost data */
+	matpath.parent = NULL;
 	cost_material(&matpath,
+				  enable_material,
 				  subplan->disabled_nodes,
 				  subplan->startup_cost,
 				  subplan->total_cost,
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index baf0d9420a2..695b0da017d 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -462,6 +462,53 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 		tuple_fraction = 0.0;
 	}
 
+	/*
+	 * Compute the initial path generation strategy mask.
+	 *
+	 * Some strategies, such as PGS_FOREIGNJOIN, have no corresponding enable_*
+	 * GUC, and so the corresponding bits are always set in the default
+	 * strategy mask.
+	 *
+	 * It may seem surprising that enable_indexscan sets both PGS_INDEXSCAN
+	 * and PGS_INDEXONLYSCAN. However, the historical behavior of this GUC
+	 * corresponds to this exactly: enable_indexscan=off disables both
+	 * index-scan and index-only scan paths, whereas enable_indexonlyscan=off
+	 * converts the index-only scan paths that we would have considered into
+	 * index scan paths.
+	 */
+	glob->default_pgs_mask = PGS_APPEND | PGS_MERGE_APPEND | PGS_FOREIGNJOIN |
+		PGS_GATHER | PGS_CONSIDER_NONPARTIAL;
+	if (enable_tidscan)
+		glob->default_pgs_mask |= PGS_TIDSCAN;
+	if (enable_seqscan)
+		glob->default_pgs_mask |= PGS_SEQSCAN;
+	if (enable_indexscan)
+		glob->default_pgs_mask |= PGS_INDEXSCAN | PGS_INDEXONLYSCAN;
+	if (enable_indexonlyscan)
+		glob->default_pgs_mask |= PGS_CONSIDER_INDEXONLY;
+	if (enable_bitmapscan)
+		glob->default_pgs_mask |= PGS_BITMAPSCAN;
+	if (enable_mergejoin)
+	{
+		glob->default_pgs_mask |= PGS_MERGEJOIN_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
+	if (enable_nestloop)
+	{
+		glob->default_pgs_mask |= PGS_NESTLOOP_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MATERIALIZE;
+		if (enable_memoize)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MEMOIZE;
+	}
+	if (enable_hashjoin)
+		glob->default_pgs_mask |= PGS_HASHJOIN;
+	if (enable_gathermerge)
+		glob->default_pgs_mask |= PGS_GATHER_MERGE;
+	if (enable_partitionwise_join)
+		glob->default_pgs_mask |= PGS_CONSIDER_PARTITIONWISE;
+
 	/* Allow plugins to take control after we've initialized "glob" */
 	if (planner_setup_hook)
 		(*planner_setup_hook) (glob, parse, query_string, &tuple_fraction, es);
@@ -3954,6 +4001,9 @@ make_grouping_rel(PlannerInfo *root, RelOptInfo *input_rel,
 		is_parallel_safe(root, havingQual))
 		grouped_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed */
+	grouped_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the grouped rel.
 	 */
@@ -5348,6 +5398,9 @@ create_ordered_paths(PlannerInfo *root,
 	if (input_rel->consider_parallel && target_parallel_safe)
 		ordered_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed. */
+	ordered_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the ordered_rel.
 	 */
@@ -7428,6 +7481,7 @@ create_partial_grouping_paths(PlannerInfo *root,
 											grouped_rel->relids);
 	partially_grouped_rel->consider_parallel =
 		grouped_rel->consider_parallel;
+	partially_grouped_rel->pgs_mask = grouped_rel->pgs_mask;
 	partially_grouped_rel->reloptkind = grouped_rel->reloptkind;
 	partially_grouped_rel->serverid = grouped_rel->serverid;
 	partially_grouped_rel->userid = grouped_rel->userid;
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 33ce34f0088..7dd9a7c4609 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1659,7 +1659,7 @@ create_group_result_path(PlannerInfo *root, RelOptInfo *rel,
  *	  pathnode.
  */
 MaterialPath *
-create_material_path(RelOptInfo *rel, Path *subpath)
+create_material_path(RelOptInfo *rel, Path *subpath, bool enabled)
 {
 	MaterialPath *pathnode = makeNode(MaterialPath);
 
@@ -1678,6 +1678,7 @@ create_material_path(RelOptInfo *rel, Path *subpath)
 	pathnode->subpath = subpath;
 
 	cost_material(&pathnode->path,
+				  enabled,
 				  subpath->disabled_nodes,
 				  subpath->startup_cost,
 				  subpath->total_cost,
@@ -1730,8 +1731,15 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath,
 	pathnode->est_unique_keys = 0.0;
 	pathnode->est_hit_ratio = 0.0;
 
-	/* we should not generate this path type when enable_memoize=false */
-	Assert(enable_memoize);
+	/*
+	 * We should not be asked to generate this path type when memoization is
+	 * disabled, so set our count of disabled nodes equal to the subpath's
+	 * count.
+	 *
+	 * It would be nice to also Assert that memoization is enabled, but the
+	 * value of enable_memoize is not controlling: what we would need to check
+	 * is that the JoinPathExtraData's pgs_mask included PGS_NESTLOOP_MEMOIZE.
+	 */
 	pathnode->path.disabled_nodes = subpath->disabled_nodes;
 
 	/*
@@ -3965,13 +3973,16 @@ reparameterize_path(PlannerInfo *root, Path *path,
 			{
 				MaterialPath *mpath = (MaterialPath *) path;
 				Path	   *spath = mpath->subpath;
+				bool		enabled;
 
 				spath = reparameterize_path(root, spath,
 											required_outer,
 											loop_count);
+				enabled =
+					(mpath->path.disabled_nodes <= spath->disabled_nodes);
 				if (spath == NULL)
 					return NULL;
-				return (Path *) create_material_path(rel, spath);
+				return (Path *) create_material_path(rel, spath, enabled);
 			}
 		case T_Memoize:
 			{
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index 07f92fac239..a6df85da105 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -557,6 +557,9 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 	 * Allow a plugin to editorialize on the info we obtained from the
 	 * catalogs.  Actions might include altering the assumed relation size,
 	 * removing an index, or adding a hypothetical index to the indexlist.
+	 *
+	 * An extension can also modify rel->pgs_mask here to control path
+	 * generation.
 	 */
 	if (get_relation_info_hook)
 		(*get_relation_info_hook) (root, relationObjectId, inhparent, rel);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 1158bc194c3..034d0c9c87a 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -47,6 +47,9 @@ typedef struct JoinHashEntry
 	RelOptInfo *join_rel;
 } JoinHashEntry;
 
+/* Hook for plugins to get control during joinrel setup */
+joinrel_setup_hook_type joinrel_setup_hook = NULL;
+
 static void build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 								RelOptInfo *input_rel,
 								SpecialJoinInfo *sjinfo,
@@ -225,6 +228,7 @@ build_simple_rel(PlannerInfo *root, int relid, RelOptInfo *parent)
 	rel->consider_startup = (root->tuple_fraction > 0);
 	rel->consider_param_startup = false;	/* might get changed later */
 	rel->consider_parallel = false; /* might get changed later */
+	rel->pgs_mask = root->glob->default_pgs_mask;
 	rel->reltarget = create_empty_pathtarget();
 	rel->pathlist = NIL;
 	rel->ppilist = NIL;
@@ -822,6 +826,7 @@ build_join_rel(PlannerInfo *root,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -934,10 +939,6 @@ build_join_rel(PlannerInfo *root,
 	 */
 	joinrel->has_eclass_joins = has_relevant_eclass_joinclause(root, joinrel);
 
-	/* Store the partition information. */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/*
 	 * Set estimates of the joinrel's size.
 	 */
@@ -963,6 +964,18 @@ build_join_rel(PlannerInfo *root,
 		is_parallel_safe(root, (Node *) joinrel->reltarget->exprs))
 		joinrel->consider_parallel = true;
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Store the partition information. */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* Add the joinrel to the PlannerInfo. */
 	add_join_rel(root, joinrel);
 
@@ -1019,6 +1032,7 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -1102,10 +1116,6 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	 */
 	joinrel->has_eclass_joins = parent_joinrel->has_eclass_joins;
 
-	/* Is the join between partitions itself partitioned? */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/* Child joinrel is parallel safe if parent is parallel safe. */
 	joinrel->consider_parallel = parent_joinrel->consider_parallel;
 
@@ -1113,6 +1123,20 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	set_joinrel_size_estimates(root, joinrel, outer_rel, inner_rel,
 							   sjinfo, restrictlist);
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel,
+	 * although the latter would be better done in the parent joinrel rather
+	 * than here.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Is the join between partitions itself partitioned? */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* We build the join only once. */
 	Assert(!find_join_rel(root, joinrel->relids));
 
@@ -1602,6 +1626,7 @@ fetch_upper_rel(PlannerInfo *root, UpperRelationKind kind, Relids relids)
 	upperrel = makeNode(RelOptInfo);
 	upperrel->reloptkind = RELOPT_UPPER_REL;
 	upperrel->relids = bms_copy(relids);
+	upperrel->pgs_mask = root->glob->default_pgs_mask;
 
 	/* cheap startup cost is interesting iff not all tuples to be retrieved */
 	upperrel->consider_startup = (root->tuple_fraction > 0);
@@ -2118,7 +2143,7 @@ build_joinrel_partition_info(PlannerInfo *root,
 	PartitionScheme part_scheme;
 
 	/* Nothing to do if partitionwise join technique is disabled. */
-	if (!enable_partitionwise_join)
+	if ((joinrel->pgs_mask & PGS_CONSIDER_PARTITIONWISE) == 0)
 	{
 		Assert(!IS_PARTITIONED_REL(joinrel));
 		return;
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index e168b17cd28..8651ccbcc30 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -22,6 +22,79 @@
 #include "nodes/parsenodes.h"
 #include "storage/block.h"
 
+/*
+ * Path generation strategies.
+ *
+ * These constants are used to specify the set of strategies that the planner
+ * should use, either for the query as a whole or for a specific baserel or
+ * joinrel. The various planner-related enable_* GUCs are used to set the
+ * PlannerGlobal's default_pgs_mask, and that in turn is used to set each
+ * RelOptInfo's pgs_mask. In both cases, extensions can use hooks to modify the
+ * default value.  Not every strategy listed here has a corresponding enable_*
+ * GUC; those that don't are always allowed unless disabled by an extension.
+ * Not all strategies are relevant for every RelOptInfo; e.g. PGS_SEQSCAN
+ * doesn't affect joinrels one way or the other.
+ *
+ * In most cases, disabling a path generation strategy merely means that any
+ * paths generated using that strategy are marked as disabled, but in some
+ * cases, path generation is skipped altogether. The latter strategy is only
+ * permissible when it can't result in planner failure -- for instance, we
+ * couldn't do this for sequential scans on a plain rel, because there might
+ * not be any other possible path. Nevertheless, the behaviors in each
+ * individual case are to some extent the result of historical accident,
+ * chosen to match the preexisting behaviors of the enable_* GUCs.
+ *
+ * In a few cases, we have more than one bit for the same strategy, controlling
+ * different aspects of the planner behavior. When PGS_CONSIDER_INDEXONLY is
+ * unset, we don't even consider index-only scans, and any such scans that
+ * would have been generated become index scans instead. On the other hand,
+ * unsetting PGS_INDEXSCAN or PGS_INDEXONLYSCAN causes generated paths of the
+ * corresponding types to be marked as disabled. Similarly, unsetting
+ * PGS_CONSIDER_PARTITIONWISE prevents any sort of thinking about partitionwise
+ * joins for the current rel, which incidentally will preclude higher-level
+ * joinrels from building parititonwise paths using paths taken from the
+ * current rel's children. On the other hand, unsetting PGS_APPEND or
+ * PGS_MERGE_APPEND will only arrange to disable paths of the corresponding
+ * types if they are generated at the level of the current rel.
+ *
+ * Finally, unsetting PGS_CONSIDER_NONPARTIAL disables all non-partial paths
+ * except those that use Gather or Gather Merge. In most other cases, a
+ * plugin can nudge the planner toward a particular strategy by disabling
+ * all of the others, but that doesn't work here: unsetting PGS_SEQSCAN,
+ * for instance, would disable both partial and non-partial sequential scans.
+ */
+#define PGS_SEQSCAN					0x00000001
+#define PGS_INDEXSCAN				0x00000002
+#define PGS_INDEXONLYSCAN			0x00000004
+#define PGS_BITMAPSCAN				0x00000008
+#define PGS_TIDSCAN					0x00000010
+#define PGS_FOREIGNJOIN				0x00000020
+#define PGS_MERGEJOIN_PLAIN			0x00000040
+#define PGS_MERGEJOIN_MATERIALIZE	0x00000080
+#define PGS_NESTLOOP_PLAIN			0x00000100
+#define PGS_NESTLOOP_MATERIALIZE	0x00000200
+#define PGS_NESTLOOP_MEMOIZE		0x00000400
+#define PGS_HASHJOIN				0x00000800
+#define PGS_APPEND					0x00001000
+#define PGS_MERGE_APPEND			0x00002000
+#define PGS_GATHER					0x00004000
+#define PGS_GATHER_MERGE			0x00008000
+#define PGS_CONSIDER_INDEXONLY		0x00010000
+#define PGS_CONSIDER_PARTITIONWISE	0x00020000
+#define PGS_CONSIDER_NONPARTIAL		0x00040000
+
+/*
+ * Convenience macros for useful combination of the bits defined above.
+ */
+#define PGS_SCAN_ANY		\
+	(PGS_SEQSCAN | PGS_INDEXSCAN | PGS_INDEXONLYSCAN | PGS_BITMAPSCAN | \
+	 PGS_TIDSCAN)
+#define PGS_MERGEJOIN_ANY	\
+	(PGS_MERGEJOIN_PLAIN | PGS_MERGEJOIN_MATERIALIZE)
+#define PGS_NESTLOOP_ANY	\
+	(PGS_NESTLOOP_PLAIN | PGS_NESTLOOP_MATERIALIZE | PGS_NESTLOOP_MEMOIZE)
+#define PGS_JOIN_ANY		\
+	(PGS_FOREIGNJOIN | PGS_MERGEJOIN_ANY | PGS_NESTLOOP_ANY | PGS_HASHJOIN)
 
 /*
  * Relids
@@ -186,6 +259,9 @@ typedef struct PlannerGlobal
 	/* worst PROPARALLEL hazard level */
 	char		maxParallelHazard;
 
+	/* mask of allowed path generation strategies */
+	uint64		default_pgs_mask;
+
 	/* partition descriptors */
 	PartitionDirectory partition_directory pg_node_attr(read_write_ignore);
 
@@ -939,7 +1015,7 @@ typedef struct RelOptInfo
 	Cardinality rows;
 
 	/*
-	 * per-relation planner control flags
+	 * per-relation planner control
 	 */
 	/* keep cheap-startup-cost paths? */
 	bool		consider_startup;
@@ -947,6 +1023,8 @@ typedef struct RelOptInfo
 	bool		consider_param_startup;
 	/* consider parallel paths? */
 	bool		consider_parallel;
+	/* path generation strategy mask */
+	uint64		pgs_mask;
 
 	/*
 	 * default result targetlist for Paths scanning this relation; list of
@@ -3506,6 +3584,7 @@ typedef struct SemiAntiJoinFactors
  * sjinfo is extra info about special joins for selectivity estimation
  * semifactors is as shown above (only valid for SEMI/ANTI/inner_unique joins)
  * param_source_rels are OK targets for parameterization of result paths
+ * pgs_mask is a bitmask of PGS_* constants to limit the join strategy
  */
 typedef struct JoinPathExtraData
 {
@@ -3515,6 +3594,7 @@ typedef struct JoinPathExtraData
 	SpecialJoinInfo *sjinfo;
 	SemiAntiJoinFactors semifactors;
 	Relids		param_source_rels;
+	uint64		pgs_mask;
 } JoinPathExtraData;
 
 /*
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index b523bcda8f3..2d80462bece 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -125,7 +125,7 @@ extern void cost_merge_append(Path *path, PlannerInfo *root,
 							  Cost input_startup_cost, Cost input_total_cost,
 							  double tuples);
 extern void cost_material(Path *path,
-						  int input_disabled_nodes,
+						  bool enabled, int input_disabled_nodes,
 						  Cost input_startup_cost, Cost input_total_cost,
 						  double tuples, int width);
 extern void cost_agg(Path *path, PlannerInfo *root,
@@ -148,7 +148,7 @@ extern void cost_group(Path *path, PlannerInfo *root,
 					   double input_tuples);
 extern void initial_cost_nestloop(PlannerInfo *root,
 								  JoinCostWorkspace *workspace,
-								  JoinType jointype,
+								  JoinType jointype, uint64 enable_mask,
 								  Path *outer_path, Path *inner_path,
 								  JoinPathExtraData *extra);
 extern void final_cost_nestloop(PlannerInfo *root, NestPath *path,
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index dbf4702acc9..123b78cbf11 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -17,6 +17,14 @@
 #include "nodes/bitmapset.h"
 #include "nodes/pathnodes.h"
 
+/* Hook for plugins to get control during joinrel setup */
+typedef void (*joinrel_setup_hook_type) (PlannerInfo *root,
+										 RelOptInfo *joinrel,
+										 RelOptInfo *outer_rel,
+										 RelOptInfo *inner_rel,
+										 SpecialJoinInfo *sjinfo,
+										 List *restrictlist);
+extern PGDLLIMPORT joinrel_setup_hook_type joinrel_setup_hook;
 
 /*
  * prototypes for pathnode.c
@@ -85,7 +93,8 @@ extern GroupResultPath *create_group_result_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 PathTarget *target,
 												 List *havingqual);
-extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath);
+extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath,
+										  bool enabled);
 extern MemoizePath *create_memoize_path(PlannerInfo *root,
 										RelOptInfo *rel,
 										Path *subpath,
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index f6a62df0b43..61c1607f872 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -28,7 +28,14 @@ extern PGDLLIMPORT int min_parallel_table_scan_size;
 extern PGDLLIMPORT int min_parallel_index_scan_size;
 extern PGDLLIMPORT bool enable_group_by_reordering;
 
-/* Hook for plugins to get control in set_rel_pathlist() */
+/* Hooks for plugins to get control in set_rel_pathlist() */
+typedef void (*join_path_setup_hook_type) (PlannerInfo *root,
+										   RelOptInfo *joinrel,
+										   RelOptInfo *outerrel,
+										   RelOptInfo *innerrel,
+										   JoinType jointype,
+										   JoinPathExtraData *extra);
+extern PGDLLIMPORT join_path_setup_hook_type join_path_setup_hook;
 typedef void (*set_rel_pathlist_hook_type) (PlannerInfo *root,
 											RelOptInfo *rel,
 											Index rti,
-- 
2.51.0



  [application/octet-stream] v5-0005-WIP-Add-pg_plan_advice-contrib-module.patch (375.1K, 6-v5-0005-WIP-Add-pg_plan_advice-contrib-module.patch)
  download | inline diff:
From 2be1025a29b1649bb1a92b60879ea74ed9541db0 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 4 Nov 2025 14:45:31 -0500
Subject: [PATCH v5 5/6] WIP: Add pg_plan_advice contrib module.

Provide a facility that (1) can be used to stabilize certain plan choices
so that the planner cannot reverse course without authorization and
(2) can be used by knowledgeable users to insist on plan choices contrary
to what the planner believes best. In both cases, terrible outcomes are
possible: users should think twice and perhaps three times before
constraining the planner's ability to do as it thinks best; nevertheless,
there are problems that are much more easily solved with these facilities
than without them.

We take the approach of analyzing a finished plan to produce textual
output, which we call "plan advice", that describes key decisions made
during plan; if that plan advice is provided during future planning
cycles, it will force those key decisions to be made in the same way.
Not all planner decisions can be controlled using advice; for example,
decisions about how to perform aggregation are currently out of scope,
as is choice of sort order. Plan advice can also be edited by the user,
or even written from scratch in simple cases, making it possible to
generate outcomes that the planner would not have produced. Partial
advice can be provided to control some planner outcomes but not others.

Currently, plan advice is focused only on specific outcomes, such as
the choice to use a sequential scan for a particular relation, and not
on estimates that might contribute to those outcomes, such as a
possibly-incorrect selectivity estimate. While it would be useful to
users to be able to provide plan advice that affects selectivity
estimates or other aspects of costing, that is out of scope for this
commit.

For more details, see contrib/pg_plan_advice/README.

NOTE: This code is just a proof of concept. A bunch of things don't
work and a lot of the code needs cleanup. It has no SGML documentation
and not enough test cases, and some of the existing test cases don't
do as we would hope. Known problems are called out by XXX.
---
 contrib/Makefile                              |    1 +
 contrib/meson.build                           |    1 +
 contrib/pg_plan_advice/.gitignore             |    3 +
 contrib/pg_plan_advice/Makefile               |   50 +
 contrib/pg_plan_advice/README                 |  275 +++
 contrib/pg_plan_advice/expected/gather.out    |  320 ++++
 .../pg_plan_advice/expected/join_order.out    |  292 +++
 .../pg_plan_advice/expected/join_strategy.out |  297 +++
 .../expected/local_collector.out              |   65 +
 .../pg_plan_advice/expected/partitionwise.out |  243 +++
 contrib/pg_plan_advice/expected/scan.out      |  757 ++++++++
 contrib/pg_plan_advice/expected/syntax.out    |   59 +
 contrib/pg_plan_advice/meson.build            |   70 +
 .../pg_plan_advice/pg_plan_advice--1.0.sql    |   42 +
 contrib/pg_plan_advice/pg_plan_advice.c       |  454 +++++
 contrib/pg_plan_advice/pg_plan_advice.control |    5 +
 contrib/pg_plan_advice/pg_plan_advice.h       |   37 +
 contrib/pg_plan_advice/pgpa_ast.c             |  392 ++++
 contrib/pg_plan_advice/pgpa_ast.h             |  204 ++
 contrib/pg_plan_advice/pgpa_collector.c       |  637 ++++++
 contrib/pg_plan_advice/pgpa_collector.h       |   18 +
 contrib/pg_plan_advice/pgpa_identifier.c      |  476 +++++
 contrib/pg_plan_advice/pgpa_identifier.h      |   52 +
 contrib/pg_plan_advice/pgpa_join.c            |  615 ++++++
 contrib/pg_plan_advice/pgpa_join.h            |  105 +
 contrib/pg_plan_advice/pgpa_output.c          |  628 ++++++
 contrib/pg_plan_advice/pgpa_output.h          |   22 +
 contrib/pg_plan_advice/pgpa_parser.y          |  337 ++++
 contrib/pg_plan_advice/pgpa_planner.c         | 1706 +++++++++++++++++
 contrib/pg_plan_advice/pgpa_planner.h         |   17 +
 contrib/pg_plan_advice/pgpa_scan.c            |  258 +++
 contrib/pg_plan_advice/pgpa_scan.h            |   86 +
 contrib/pg_plan_advice/pgpa_scanner.l         |  299 +++
 contrib/pg_plan_advice/pgpa_trove.c           |  490 +++++
 contrib/pg_plan_advice/pgpa_trove.h           |  113 ++
 contrib/pg_plan_advice/pgpa_walker.c          |  890 +++++++++
 contrib/pg_plan_advice/pgpa_walker.h          |  122 ++
 contrib/pg_plan_advice/sql/gather.sql         |   76 +
 contrib/pg_plan_advice/sql/join_order.sql     |   96 +
 contrib/pg_plan_advice/sql/join_strategy.sql  |   76 +
 .../pg_plan_advice/sql/local_collector.sql    |   41 +
 contrib/pg_plan_advice/sql/partitionwise.sql  |   78 +
 contrib/pg_plan_advice/sql/scan.sql           |  195 ++
 contrib/pg_plan_advice/sql/syntax.sql         |   42 +
 contrib/pg_plan_advice/t/001_regress.pl       |  147 ++
 src/tools/pgindent/typedefs.list              |   37 +
 46 files changed, 11226 insertions(+)
 create mode 100644 contrib/pg_plan_advice/.gitignore
 create mode 100644 contrib/pg_plan_advice/Makefile
 create mode 100644 contrib/pg_plan_advice/README
 create mode 100644 contrib/pg_plan_advice/expected/gather.out
 create mode 100644 contrib/pg_plan_advice/expected/join_order.out
 create mode 100644 contrib/pg_plan_advice/expected/join_strategy.out
 create mode 100644 contrib/pg_plan_advice/expected/local_collector.out
 create mode 100644 contrib/pg_plan_advice/expected/partitionwise.out
 create mode 100644 contrib/pg_plan_advice/expected/scan.out
 create mode 100644 contrib/pg_plan_advice/expected/syntax.out
 create mode 100644 contrib/pg_plan_advice/meson.build
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice--1.0.sql
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.c
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.control
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.h
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.c
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.h
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.c
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.h
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.c
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.h
 create mode 100644 contrib/pg_plan_advice/pgpa_join.c
 create mode 100644 contrib/pg_plan_advice/pgpa_join.h
 create mode 100644 contrib/pg_plan_advice/pgpa_output.c
 create mode 100644 contrib/pg_plan_advice/pgpa_output.h
 create mode 100644 contrib/pg_plan_advice/pgpa_parser.y
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.c
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.c
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scanner.l
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.c
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.h
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.c
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.h
 create mode 100644 contrib/pg_plan_advice/sql/gather.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_order.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_strategy.sql
 create mode 100644 contrib/pg_plan_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_plan_advice/sql/partitionwise.sql
 create mode 100644 contrib/pg_plan_advice/sql/scan.sql
 create mode 100644 contrib/pg_plan_advice/sql/syntax.sql
 create mode 100644 contrib/pg_plan_advice/t/001_regress.pl

diff --git a/contrib/Makefile b/contrib/Makefile
index 2f0a88d3f77..dd04c20acd2 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -34,6 +34,7 @@ SUBDIRS = \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
+		pg_plan_advice \
 		pg_prewarm	\
 		pg_stat_statements \
 		pg_surgery	\
diff --git a/contrib/meson.build b/contrib/meson.build
index ed30ee7d639..cb718dbdac0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -48,6 +48,7 @@ subdir('pgcrypto')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
+subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
 subdir('pg_stat_statements')
diff --git a/contrib/pg_plan_advice/.gitignore b/contrib/pg_plan_advice/.gitignore
new file mode 100644
index 00000000000..19a14253019
--- /dev/null
+++ b/contrib/pg_plan_advice/.gitignore
@@ -0,0 +1,3 @@
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
new file mode 100644
index 00000000000..1d4c559aed8
--- /dev/null
+++ b/contrib/pg_plan_advice/Makefile
@@ -0,0 +1,50 @@
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_plan_advice.o \
+	pgpa_ast.o \
+	pgpa_collector.o \
+	pgpa_identifier.o \
+	pgpa_join.o \
+	pgpa_output.o \
+	pgpa_parser.o \
+	pgpa_planner.o \
+	pgpa_scan.o \
+	pgpa_scanner.o \
+	pgpa_trove.o \
+	pgpa_walker.o
+
+EXTENSION = pg_plan_advice
+DATA = pg_plan_advice--1.0.sql
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise scan
+TAP_TESTS = 1
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+# required for 001_regress.pl
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+	touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
new file mode 100644
index 00000000000..4590cd03ce5
--- /dev/null
+++ b/contrib/pg_plan_advice/README
@@ -0,0 +1,275 @@
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planing
+cycle, each piece of advice should be guaranteed to apply to the exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should succesfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it is appears to be good advice or bad advice.  If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+   e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously.  Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables.  When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by curly brances, e.g. SOME_SORT_OF_SCAN(foo (bar baz)) would mean that the
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+Bitmap heap scans allow for a more complicated index specification. For
+example, BITMAP_HEAP_SCAN(foo &&(foo_a_idx ||(foo_b_idx foo_c_idx))) says
+that foo should be scanned using a BitmapHeapScan over a BitmapAnd between
+foo_a_idx and the result of a BitmapOr between foo_b_idx and foo_c_idx.
+
+XXX: Currently, BITMAP_HEAP_SCAN does not enforce the index specification,
+because the available hooks are insufficient to do so. It's possible that
+this should be changed to exclude the index specification altogether and
+simply insist that some sort of bitmap heap scan is used; alternatively,
+we need better hooks.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ORDER() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructured. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Strategy Advice
+====================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+strategy advice; no join strategy advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join strategy advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the planner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join strategy advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join strategy advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join strategy advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+XXX: Currently, SEMIJOIN_NON_UNIQUE() advice is emitted in some situations
+where the SEMIJOIN_UNIQUE() approach was determined to be non-viable; ideally,
+we should avoid that.
+
+XXX: Right semijoins haven't been properly thought through. The associated
+code probably just doesn't work.
+
+XXX: Semijoin uniqueness advice has no automated tests and need substantially
+more manual testing.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advise can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constraint the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the outer
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constraint won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
new file mode 100644
index 00000000000..d0224a2aee7
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -0,0 +1,320 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Sort Key: f.dim_id
+   ->  Gather
+         Workers Planned: 1
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(f d)
+(20 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(d)
+   NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(d)
+   NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   NO_GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Disabled: true
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(14 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
new file mode 100644
index 00000000000..e87652370c3
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -0,0 +1,292 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   HASH_JOIN(d1 d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (d1.id = f.dim1_id)
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Hash
+               ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 f d2)
+   HASH_JOIN(f d2)
+   SEQ_SCAN(d1 f d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN(d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Disabled: true
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_PLAIN(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
new file mode 100644
index 00000000000..71ee26a337a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -0,0 +1,297 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Disabled: true
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Materialize
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MATERIALIZE(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Memoize
+         Cache Key: f.dim_id
+         Cache Mode: logical
+         ->  Index Scan using join_dim_pkey on join_dim d
+               Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MEMOIZE(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN              
+-------------------------------------
+ Hash Join
+   Hash Cond: (d.id = f.dim_id)
+   ->  Seq Scan on join_dim d
+   ->  Hash
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   HASH_JOIN(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Materialize
+         ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_MATERIALIZE(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_dim d
+   ->  Materialize
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MATERIALIZE(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Memoize
+         Cache Key: d.id
+         Cache Mode: logical
+         ->  Index Scan using join_fact_dim_id on join_fact f
+               Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MEMOIZE(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+         Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_PLAIN(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(13 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/local_collector.out b/contrib/pg_plan_advice/expected/local_collector.out
new file mode 100644
index 00000000000..8024a063a04
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/local_collector.out
@@ -0,0 +1,65 @@
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
new file mode 100644
index 00000000000..df0f05531d5
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -0,0 +1,243 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt2.id)
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1b pt1_2
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1c pt1_3
+               Filter: (val1 = 1)
+   ->  Hash
+         ->  Hash Join
+               Hash Cond: (pt2.id = pt3.id)
+               ->  Append
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+               ->  Hash
+                     ->  Append
+                           ->  Seq Scan on pt3a pt3_1
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3b pt3_2
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3c pt3_3
+                                 Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE(pt1) /* matched */
+   PARTITIONWISE(pt2) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 (pt2 pt3))
+   HASH_JOIN(pt3 pt3)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE(pt1 pt2 pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(40 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt3.id)
+   ->  Append
+         ->  Hash Join
+               Hash Cond: (pt1_1.id = pt2_1.id)
+               ->  Seq Scan on pt1a pt1_1
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_2.id = pt2_2.id)
+               ->  Seq Scan on pt1b pt1_2
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_3.id = pt2_3.id)
+               ->  Seq Scan on pt1c pt1_3
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+   ->  Hash
+         ->  Append
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3b pt3_2
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3c pt3_3
+                     Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+   JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+   JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+   JOIN_ORDER({pt1 pt2} pt3)
+   HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+   SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+    pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE((pt1 pt2) pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+         ->  Seq Scan on pt1b pt1_2
+         ->  Seq Scan on pt1c pt1_3
+   ->  Append
+         ->  Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+               Index Cond: (id = pt1.id)
+         ->  Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+               Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 ptmismatch)
+   NESTED_LOOP_PLAIN(ptmismatch)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+    ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+   PARTITIONWISE(pt1 ptmismatch)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+    ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
new file mode 100644
index 00000000000..61f361fcf9c
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -0,0 +1,757 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+       QUERY PLAN        
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                     QUERY PLAN                     
+----------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+            QUERY PLAN             
+-----------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_b) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a > 0)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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 > 0;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a > 0)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (a > 0)
+   ->  Bitmap Index Scan on scan_table_pkey
+         Index Cond: (a > 0)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(9 rows)
+
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_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:
+   TID_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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table cilbup.scan_table_pkey) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, conflicting */
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched, conflicting */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Nested Loop Left Join
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s s#2)
+   INDEX_SCAN(s public.scan_table_pkey s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop Left Join
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s#2)
+   HASH_JOIN(s)
+   SEQ_SCAN(s)
+   INDEX_SCAN(s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s)
+   HASH_JOIN(s#2)
+   SEQ_SCAN(s#2)
+   INDEX_SCAN(s public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   HASH_JOIN(s s#2)
+   SEQ_SCAN(s s#2)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+COMMIT;
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(5 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(5 rows)
+
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+          QUERY PLAN           
+-------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@x)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                    QUERY PLAN                    
+--------------------------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/syntax.out b/contrib/pg_plan_advice/expected/syntax.out
new file mode 100644
index 00000000000..dddb12cae58
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/syntax.out
@@ -0,0 +1,59 @@
+LOAD 'pg_plan_advice';
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQUENTIAL_SCAN(x)"
+DETAIL:  Could not parse advice: syntax error at or near "SEQUENTIAL_SCAN"
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN"
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(""
+DETAIL:  Could not parse advice: unterminated quoted identifier at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(#"
+DETAIL:  Could not parse advice: syntax error at or near "#"
+SET pg_plan_advice.advice = '()';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "()"
+DETAIL:  Could not parse advice: syntax error at or near "("
+SET pg_plan_advice.advice = '123';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "123"
+DETAIL:  Could not parse advice: syntax error at or near "123"
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "JOIN_ORDER("fOO") /* oops"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*/* stuff */*/"
+DETAIL:  Could not parse advice: syntax error at or near "*"
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN(a)"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN((a))"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
new file mode 100644
index 00000000000..3452e5ad48e
--- /dev/null
+++ b/contrib/pg_plan_advice/meson.build
@@ -0,0 +1,70 @@
+# Copyright (c) 2022-2024, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+  'pg_plan_advice.c',
+  'pgpa_ast.c',
+  'pgpa_collector.c',
+  'pgpa_identifier.c',
+  'pgpa_join.c',
+  'pgpa_output.c',
+  'pgpa_planner.c',
+  'pgpa_scan.c',
+  'pgpa_trove.c',
+  'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+  input: 'pgpa_scanner.l',
+  output: 'pgpa_scanner.c',
+  command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+  input: 'pgpa_parser.y',
+  kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+  pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_plan_advice',
+    '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice = shared_module('pg_plan_advice',
+  pg_plan_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_data(
+  'pg_plan_advice--1.0.sql',
+  'pg_plan_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gather',
+      'join_order',
+      'join_strategy',
+      'local_collector',
+      'partitionwise',
+      'scan',
+      'syntax',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_regress.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice--1.0.sql b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
new file mode 100644
index 00000000000..29f4f224864
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
@@ -0,0 +1,42 @@
+/* contrib/pg_plan_advice/pg_plan_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_plan_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_plan_advice/pg_plan_advice.c b/contrib/pg_plan_advice/pg_plan_advice.c
new file mode 100644
index 00000000000..f32e8b7a0d3
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.c
@@ -0,0 +1,454 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ *	  main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static pgpa_shared_state *pgpa_state = NULL;
+static dsa_area *pgpa_dsa_area = NULL;
+
+/* GUC variables */
+char	   *pg_plan_advice_advice = NULL;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+int			pg_plan_advice_local_collection_limit = 0;
+int			pg_plan_advice_shared_collection_limit = 0;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int	es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+												  DefElem *opt,
+												  ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+												 IntoClause *into,
+												 ExplainState *es,
+												 const char *queryString,
+												 ParamListInfo params,
+												 QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+											 GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("pg_plan_advice.advice",
+							   "advice to apply during query planning",
+							   NULL,
+							   &pg_plan_advice_advice,
+							   NULL,
+							   PGC_USERSET,
+							   0,
+							   pg_plan_advice_advice_check_hook,
+							   NULL,
+							   NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+							 "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+							 NULL,
+							 &pg_plan_advice_always_explain_supplied_advice,
+							 true,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_plan_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_plan_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_plan_advice");
+
+	/* Get an ID that we can use to cache data in an ExplainState. */
+	es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+	/* Register the new EXPLAIN options implemented by this module. */
+	RegisterExtensionExplainOption("plan_advice",
+								   pg_plan_advice_explain_option_handler);
+
+	/* Install hooks */
+	pgpa_planner_install_hooks();
+	prev_explain_per_plan = explain_per_plan_hook;
+	explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgpa_init_shared_state(void *ptr)
+{
+	pgpa_shared_state *state = (pgpa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock, LWLockNewTrancheId("pg_plan_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_plan_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+	if (pgpa_memory_context == NULL)
+		pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_plan_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgpa_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ *
+ * Along the way, make sure the relevant LWLock tranches are registered.
+ */
+pgpa_shared_state *
+pg_plan_advice_attach(void)
+{
+	if (pgpa_state == NULL)
+	{
+		bool		found;
+
+		pgpa_state =
+			GetNamedDSMSegment("pg_plan_advice", sizeof(pgpa_shared_state),
+							   pgpa_init_shared_state, &found);
+	}
+
+	return pgpa_state;
+}
+
+/*
+ * Return a pointer to pg_plan_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_plan_advice_dsa_area(void)
+{
+	if (pgpa_dsa_area == NULL)
+	{
+		pgpa_shared_state *state = pg_plan_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgpa_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgpa_dsa_area);
+			state->area = dsa_get_handle(pgpa_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgpa_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgpa_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgpa_dsa_area;
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+									  ParseState *pstate)
+{
+	bool	   *plan_advice;
+
+	plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+	if (plan_advice == NULL)
+	{
+		plan_advice = palloc0_object(bool);
+		SetExplainExtensionState(es, es_extension_id, plan_advice);
+	}
+
+	*plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+									  char *value)
+{
+	char	   *s;
+
+	/* For non-text formats, it's best not to add any special handling. */
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		ExplainPropertyText(qlabel, value, es);
+		return;
+	}
+
+	/* In text format, if there is no data, display nothing. */
+	if (*qlabel == '\0')
+		return;
+
+	/*
+	 * It looks nicest to indent each line of the advice separately, beginning
+	 * on the line below the label.
+	 */
+	ExplainIndentText(es);
+	appendStringInfo(es->str, "%s:\n", qlabel);
+	es->indent++;
+	while ((s = strchr(value, '\n')) != NULL)
+	{
+		ExplainIndentText(es);
+		appendBinaryStringInfo(es->str, value, (s - value) + 1);
+		value = s + 1;
+	}
+
+	/* Don't interpret a terminal newline as a request for an empty line. */
+	if (*value != '\0')
+	{
+		ExplainIndentText(es);
+		appendStringInfo(es->str, "%s\n", value);
+	}
+
+	es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		appendStringInfo(&buf, "%s /* ", item->defname);
+		if ((flags & PGPA_TE_MATCH_FULL) != 0)
+		{
+			Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+			appendStringInfo(&buf, "matched");
+		}
+		else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+			appendStringInfo(&buf, "partially matched");
+		else
+			appendStringInfo(&buf, "not matched");
+		if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+			appendStringInfo(&buf, ", inapplicable");
+		if ((flags & PGPA_TE_CONFLICTING) != 0)
+			appendStringInfo(&buf, ", conflicting");
+		if ((flags & PGPA_TE_FAILED) != 0)
+			appendStringInfo(&buf, ", failed");
+		appendStringInfo(&buf, " */\n");
+	}
+
+	pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+										  buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+									 IntoClause *into,
+									 ExplainState *es,
+									 const char *queryString,
+									 ParamListInfo params,
+									 QueryEnvironment *queryEnv)
+{
+	bool	   *plan_advice = GetExplainExtensionState(es, es_extension_id);
+	DefElem    *pgpa_item;
+	List	   *pgpa_list;
+
+	if (prev_explain_per_plan)
+		prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+							  queryEnv);
+
+	/* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+	pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+										"pg_plan_advice");
+	pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+	/*
+	 * By default, if there is a record of attempting to apply advice during
+	 * query planning, we always output that information, but the user can set
+	 * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+	 * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+	 * was specified and not set to false.
+	 *
+	 * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+	 * statement -- the application of query advice may not have been
+	 * recorded, and therefore this won't be able to show anything.
+	 */
+	if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+							  (plan_advice != NULL && *plan_advice)))
+	{
+		DefElem    *feedback;
+
+		feedback = find_defelem_by_defname(pgpa_list, "feedback");
+		if (feedback != NULL)
+			pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+	}
+
+	/*
+	 * If the PLAN_ADVICE option was specified -- and not sent to FALSE --
+	 * show generated advice.
+	 */
+	if (plan_advice != NULL && *plan_advice)
+	{
+		DefElem    *advice_string_item;
+		char	   *advice_string;
+
+		advice_string_item =
+			find_defelem_by_defname(pgpa_list, "advice_string");
+		if (advice_string_item != NULL)
+		{
+			/* Advice has already been generated; we can reuse it. */
+			advice_string = strVal(advice_string_item->arg);
+		}
+		else
+		{
+			pgpa_plan_walker_context walker;
+			StringInfoData buf;
+			pgpa_identifier *rt_identifiers;
+
+			/* Advice not yet generated; do that now. */
+			pgpa_plan_walker(&walker, plannedstmt);
+			rt_identifiers =
+				pgpa_create_identifiers_for_planned_stmt(plannedstmt);
+			initStringInfo(&buf);
+			pgpa_output_advice(&buf, &walker, rt_identifiers);
+			advice_string = buf.data;
+		}
+
+		if (advice_string[0] != '\0')
+			pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+												  advice_string);
+	}
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+	MemoryContext oldcontext;
+	MemoryContext tmpcontext;
+	char	   *error;
+
+	if (*newval == NULL)
+		return true;
+
+	tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+									   "pg_plan_advice.advice",
+									   ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+	/*
+	 * It would be nice to save the parse tree that we construct here for
+	 * eventual use when planning with this advice, but *extra can only point
+	 * to a single guc_malloc'd chunk, and our parse tree involves an
+	 * arbitrary number of memory allocations.
+	 */
+	(void) pgpa_parse(*newval, &error);
+
+	if (error != NULL)
+	{
+		GUC_check_errdetail("Could not parse advice: %s", error);
+		return false;
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+	MemoryContextDelete(tmpcontext);
+
+	return true;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.control b/contrib/pg_plan_advice/pg_plan_advice.control
new file mode 100644
index 00000000000..aa6fdc9e7b2
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.control
@@ -0,0 +1,5 @@
+# pg_plan_advice extension
+comment = 'help the planner get the right plan'
+default_version = '1.0'
+module_pathname = '$libdir/pg_plan_advice'
+relocatable = true
diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
new file mode 100644
index 00000000000..86efb3b6113
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -0,0 +1,37 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ *	  main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "nodes/plannodes.h"
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgpa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgpa_shared_state;
+
+/* GUC variables */
+extern int	pg_plan_advice_local_collection_limit;
+extern int	pg_plan_advice_shared_collection_limit;
+extern char *pg_plan_advice_advice;
+
+/* Function prototypes */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern pgpa_shared_state *pg_plan_advice_attach(void);
+extern dsa_area *pg_plan_advice_dsa_area(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
new file mode 100644
index 00000000000..02ffbfa3760
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -0,0 +1,392 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ *	  additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+										  pgpa_advice_target *target,
+										  bool *rids_used);
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+	switch (advice_tag)
+	{
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_FOREIGN_JOIN:
+			return "FOREIGN_JOIN";
+		case PGPA_TAG_GATHER:
+			return "GATHER";
+		case PGPA_TAG_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPA_TAG_HASH_JOIN:
+			return "HASH_JOIN";
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_TAG_INDEX_SCAN:
+			return "INDEX_SCAN";
+		case PGPA_TAG_JOIN_ORDER:
+			return "JOIN_ORDER";
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case PGPA_TAG_NO_GATHER:
+			return "NO_GATHER";
+		case PGPA_TAG_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+		case PGPA_TAG_SEQ_SCAN:
+			return "SEQ_SCAN";
+		case PGPA_TAG_TID_SCAN:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and reurn an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+	*fail = false;
+
+	switch (tag[0])
+	{
+		case 'b':
+			if (strcmp(tag, "bitmap_heap_scan") == 0)
+				return PGPA_TAG_BITMAP_HEAP_SCAN;
+			break;
+		case 'f':
+			if (strcmp(tag, "foreign_join") == 0)
+				return PGPA_TAG_FOREIGN_JOIN;
+			break;
+		case 'g':
+			if (strcmp(tag, "gather") == 0)
+				return PGPA_TAG_GATHER;
+			if (strcmp(tag, "gather_merge") == 0)
+				return PGPA_TAG_GATHER_MERGE;
+			break;
+		case 'h':
+			if (strcmp(tag, "hash_join") == 0)
+				return PGPA_TAG_HASH_JOIN;
+			break;
+		case 'i':
+			if (strcmp(tag, "index_scan") == 0)
+				return PGPA_TAG_INDEX_SCAN;
+			if (strcmp(tag, "index_only_scan") == 0)
+				return PGPA_TAG_INDEX_ONLY_SCAN;
+			break;
+		case 'j':
+			if (strcmp(tag, "join_order") == 0)
+				return PGPA_TAG_JOIN_ORDER;
+			break;
+		case 'm':
+			if (strcmp(tag, "merge_join_materialize") == 0)
+				return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+			if (strcmp(tag, "merge_join_plain") == 0)
+				return PGPA_TAG_MERGE_JOIN_PLAIN;
+			break;
+		case 'n':
+			if (strcmp(tag, "nested_loop_materialize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+			if (strcmp(tag, "nested_loop_memoize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+			if (strcmp(tag, "nested_loop_plain") == 0)
+				return PGPA_TAG_NESTED_LOOP_PLAIN;
+			if (strcmp(tag, "no_gather") == 0)
+				return PGPA_TAG_NO_GATHER;
+			break;
+		case 'p':
+			if (strcmp(tag, "partitionwise") == 0)
+				return PGPA_TAG_PARTITIONWISE;
+			break;
+		case 's':
+			if (strcmp(tag, "semijoin_non_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+			if (strcmp(tag, "semijoin_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_UNIQUE;
+			if (strcmp(tag, "seq_scan") == 0)
+				return PGPA_TAG_SEQ_SCAN;
+			break;
+		case 't':
+			if (strcmp(tag, "tid_scan") == 0)
+				return PGPA_TAG_TID_SCAN;
+			break;
+	}
+
+	/* didn't work out */
+	*fail = true;
+
+	/* return an arbitrary value to unwind the call stack */
+	return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		bool		first = true;
+		char	   *delims;
+
+		if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+			delims = "{}";
+		else
+			delims = "()";
+
+		appendStringInfoChar(str, delims[0]);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_advice_target(str, child_target);
+		}
+		appendStringInfoChar(str, delims[1]);
+	}
+	else
+	{
+		const char *rt_identifier;
+
+		rt_identifier = pgpa_identifier_string(&target->rid);
+		appendStringInfoString(str, rt_identifier);
+	}
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+	if (itarget->itype != PGPA_INDEX_NAME)
+	{
+		bool		first = true;
+
+		if (itarget->itype == PGPA_INDEX_AND)
+			appendStringInfoString(str, "&&(");
+		else
+			appendStringInfoString(str, "||(");
+
+		foreach_ptr(pgpa_index_target, child_target, itarget->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_index_target(str, child_target);
+		}
+		appendStringInfoChar(str, ')');
+	}
+	else
+	{
+		if (itarget->indnamespace != NULL)
+			appendStringInfo(str, "%s.",
+							 quote_identifier(itarget->indnamespace));
+		appendStringInfoString(str, quote_identifier(itarget->indname));
+	}
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+	if (i1->itype != i2->itype)
+		return false;
+
+	if (i1->itype == PGPA_INDEX_NAME)
+	{
+		/* indnamespace can be NULL, and two NULL values are equal */
+		if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+			(i1->indnamespace == NULL || i2->indnamespace == NULL ||
+			 strcmp(i1->indnamespace, i2->indnamespace) != 0))
+			return false;
+		if (strcmp(i1->indname, i2->indname) != 0)
+			return false;
+	}
+	else
+	{
+		int			i1_length = list_length(i1->children);
+
+		if (i1_length != list_length(i2->children))
+			return false;
+		for (int n = 0; n < i1_length; ++n)
+		{
+			pgpa_index_target *c1 = list_nth(i1->children, n);
+			pgpa_index_target *c2 = list_nth(i2->children, n);
+
+			if (!pgpa_index_targets_equal(c1, c2))
+				return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+	/* For non-identifiers, check all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (pgpa_identifier_matches_target(rid, child_target))
+				return true;
+		}
+		return false;
+	}
+
+	if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+		return false;
+	if (rid->occurrence != target->rid.occurrence)
+		return false;
+
+	/*
+	 * The identifier must specify a schema, but the target may leave the
+	 * schema NULL to match anything.
+	 */
+	if (target->rid.partnsp != NULL &&
+		strcmp(rid->partnsp, target->rid.partnsp) != 0)
+		return false;
+
+
+	/*
+	 * These fields can be NULL on either side, but NULL only matches another
+	 * NULL.
+	 */
+	if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+		return false;
+	if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+		return false;
+
+	return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target)
+{
+	bool		all_rids_used = true;
+	bool		any_rids_used = false;
+	bool		all_targets_used;
+	bool	   *rids_used = palloc0_array(bool, nrids);
+
+	all_targets_used =
+		pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+	for (int i = 0; i < nrids; ++i)
+	{
+		if (rids_used[i])
+			any_rids_used = true;
+		else
+			all_rids_used = false;
+	}
+
+	if (all_rids_used)
+	{
+		if (all_targets_used)
+			return PGPA_ITM_EQUAL;
+		else
+			return PGPA_ITM_KEYS_ARE_SUBSET;
+	}
+	else
+	{
+		if (all_targets_used)
+			return PGPA_ITM_TARGETS_ARE_SUBSET;
+		else if (any_rids_used)
+			return PGPA_ITM_INTERSECTING;
+		else
+			return PGPA_ITM_DISJOINT;
+	}
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target, bool *rids_used)
+{
+	bool		result = false;
+
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		result = true;
+
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+											   rids_used))
+				result = false;
+		}
+	}
+	else
+	{
+		for (int i = 0; i < nrids; ++i)
+		{
+			if (pgpa_identifier_matches_target(&rids[i], target))
+			{
+				rids_used[i] = true;
+				result = true;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
new file mode 100644
index 00000000000..f6fe730a4d4
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -0,0 +1,204 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ *	  abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+	PGPA_TARGET_IDENTIFIER,		/* relation identifier */
+	PGPA_TARGET_ORDERED_LIST,	/* (item ...) */
+	PGPA_TARGET_UNORDERED_LIST	/* {item ...} */
+} pgpa_target_type;
+
+/*
+ * When an advice item describes a bitmap index scan, it may need to describe
+ * the use of multiple indexes.
+ */
+typedef enum
+{
+	PGPA_INDEX_NAME,			/* index schema + name */
+	PGPA_INDEX_AND,				/* &&(item ...) */
+	PGPA_INDEX_OR				/* ||(item ...) */
+} pgpa_index_type;
+
+/*
+ * An index specification. We use this for INDEX_SCAN, INDEX_ONLY_SCAN,
+ * and BITMAP_HEAP_SCAN advice, but in the former two cases, the target must
+ * be of type PGPA_INDEX_NAME.
+ */
+typedef struct pgpa_index_target
+{
+	pgpa_index_type itype;
+
+	/* Index schem and name, when itype == PGPA_INDEX_NAME */
+	char	   *indnamespace;
+	char	   *indname;
+
+	/* List of pgpa_index_target objects, when itype != PGPA_INDEX_NAME */
+	List	   *children;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+	pgpa_target_type ttype;
+
+	/*
+	 * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+	 *
+	 * All identifiers must have an alias name and an occurrence number; the
+	 * remaining fields can be NULL. Note that it's possible to specify a
+	 * partition name without a partition schema, but not the reverse.
+	 */
+	pgpa_identifier rid;
+
+	/*
+	 * This field is set when ttype is PPGA_TARGET_IDENTIFIER and the advice
+	 * tag is PGPA_TAG_INDEX_SCAN, PGPA_TAG_INDEX_ONLY_SCAN, or
+	 * PGPA_TAG_BITMAP_HEAP_SCAN.
+	 */
+	pgpa_index_target *itarget;
+
+	/*
+	 * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+	 * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+	 */
+	List	   *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_FOREIGN_JOIN,
+	PGPA_TAG_GATHER,
+	PGPA_TAG_GATHER_MERGE,
+	PGPA_TAG_HASH_JOIN,
+	PGPA_TAG_INDEX_ONLY_SCAN,
+	PGPA_TAG_INDEX_SCAN,
+	PGPA_TAG_JOIN_ORDER,
+	PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+	PGPA_TAG_MERGE_JOIN_PLAIN,
+	PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+	PGPA_TAG_NESTED_LOOP_MEMOIZE,
+	PGPA_TAG_NESTED_LOOP_PLAIN,
+	PGPA_TAG_NO_GATHER,
+	PGPA_TAG_PARTITIONWISE,
+	PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+	PGPA_TAG_SEMIJOIN_UNIQUE,
+	PGPA_TAG_SEQ_SCAN,
+	PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+	pgpa_advice_tag_type tag;
+	List	   *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by an
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifeirs and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifeirs and targets were
+ * found.
+ */
+typedef enum
+{
+	PGPA_ITM_EQUAL,
+	PGPA_ITM_KEYS_ARE_SUBSET,
+	PGPA_ITM_TARGETS_ARE_SUBSET,
+	PGPA_ITM_INTERSECTING,
+	PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int	pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+					   char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner,
+						 const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int	pgpa_yyparse(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+										   pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+												   pgpa_identifier *rids,
+												   pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+									 pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+									  pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+									 pgpa_index_target *itarget);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_collector.c b/contrib/pg_plan_advice/pgpa_collector.c
new file mode 100644
index 00000000000..12085d9d75f
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.c
@@ -0,0 +1,637 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.c
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgpa_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgpa_collected_advice;
+
+/*
+ * A bunch of pointers to pgpa_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgpa_local_advice_chunk
+{
+	pgpa_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgpa_local_advice_chunk;
+
+/*
+ * Information about all of the pgpa_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgpa_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgpa_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgpa_local_advice_chunk **chunks;
+} pgpa_local_advice;
+
+/*
+ * Just like pgpa_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgpa_shared_advice_chunk;
+
+/*
+ * Just like pgpa_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgpa_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgpa_local_advice *local_collector = NULL;
+static pgpa_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgpa_collected_advice *pgpa_make_collected_advice(Oid userid,
+														 Oid dbid,
+														 uint64 queryId,
+														 TimestampTz timestamp,
+														 const char *query_string,
+														 const char *advice_string,
+														 dsa_area *area,
+														 dsa_pointer *result);
+static void pgpa_store_local_advice(pgpa_collected_advice *ca);
+static void pgpa_trim_local_advice(int limit);
+static void pgpa_store_shared_advice(dsa_pointer ca_pointer);
+static void pgpa_trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgpa_collected_advice */
+static inline const char *
+query_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgpa_collected_advice */
+static inline const char *
+advice_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pgpa_collect_advice(uint64 queryId, const char *query_string,
+					const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_plan_advice_local_collection_limit > 0)
+	{
+		pgpa_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+		ca = pgpa_make_collected_advice(userid, dbid, queryId, now,
+										query_string, advice_string,
+										NULL, NULL);
+		pgpa_store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_plan_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_plan_advice_dsa_area();
+		dsa_pointer ca_pointer;
+
+		pgpa_make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string, area,
+								   &ca_pointer);
+		pgpa_store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgpa_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgpa_collected_advice *
+pgpa_make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+						   TimestampTz timestamp,
+						   const char *query_string,
+						   const char *advice_string,
+						   dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgpa_collected_advice *ca;
+
+	total_length = offsetof(pgpa_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = GetUserId();
+	ca->dbid = MyDatabaseId;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pg_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+pgpa_store_local_advice(pgpa_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgpa_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgpa_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgpa_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgpa_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_local_advice(pg_plan_advice_local_collection_limit);
+}
+
+/*
+ * Add a pg_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_plan_advice DSA area
+ * and should point to an object of type pgpa_collected_advice.
+ */
+static void
+pgpa_store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	pgpa_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgpa_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgpa_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_shared_advice(area, pg_plan_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_local_advice(int limit)
+{
+	pgpa_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgpa_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgpa_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_shared_advice(dsa_area *area, int limit)
+{
+	pgpa_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(pgpa_shared_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		pgpa_trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	pgpa_trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice *sa = shared_collector;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_plan_advice/pgpa_collector.h b/contrib/pg_plan_advice/pgpa_collector.h
new file mode 100644
index 00000000000..b6e746a06d7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.h
@@ -0,0 +1,18 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.h
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_COLLECTOR_H
+#define PGPA_COLLECTOR_H
+
+extern void pgpa_collect_advice(uint64 queryId, const char *query_string,
+								const char *advice_string);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_identifier.c b/contrib/pg_plan_advice/pgpa_identifier.c
new file mode 100644
index 00000000000..2fa8075d66e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.c
@@ -0,0 +1,476 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ *	  create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted.  Identifier-style escaping is applied to alias_name and
+ * subquery_name.  Whenever we include child_table_name, we always
+ * schema-qualified name, but writing their own plan advice are not required
+ * to do so.  Identifier-style escaping is applied to the schema and to the
+ * relation names separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+									  List *appinfos);
+static int	pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+								   SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+	const char *result;
+
+	Assert(rid->alias_name != NULL);
+	result = quote_identifier(rid->alias_name);
+
+	Assert(rid->occurrence >= 0);
+	if (rid->occurrence > 1)
+		result = psprintf("%s#%d", result, rid->occurrence);
+
+	if (rid->partrel != NULL)
+	{
+		if (rid->partnsp == NULL)
+			result = psprintf("%s/%s", result,
+							  quote_identifier(rid->partnsp));
+		else
+			result = psprintf("%s/%s.%s", result,
+							  quote_identifier(rid->partnsp),
+							  quote_identifier(rid->partrel));
+	}
+
+	if (rid->plan_name != NULL)
+		result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+	return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+							   pgpa_identifier *rid)
+{
+	Index		top_rti = rti;
+	int			occurrence = 1;
+	RangeTblEntry *rte;
+	RangeTblEntry *top_rte;
+	char	   *partnsp = NULL;
+	char	   *partrel = NULL;
+
+	/*
+	 * If this is a child RTE, find the topmost parent that is still of type
+	 * RTE_RELATION. We do this because we identify children of partitioned
+	 * tables by the name of the child table, but subqueries can also have
+	 * child rels and we don't care about those here.
+	 */
+	for (;;)
+	{
+		AppendRelInfo *appinfo;
+		RangeTblEntry *parent_rte;
+
+		/* append_rel_array can be NULL if there are no children */
+		if (root->append_rel_array == NULL ||
+			(appinfo = root->append_rel_array[top_rti]) == NULL)
+			break;
+
+		parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+		if (parent_rte->rtekind != RTE_RELATION)
+			break;
+
+		top_rti = appinfo->parent_relid;
+	}
+
+	/* Get the range table entries for the RTI and top RTI. */
+	rte = planner_rt_fetch(rti, root);
+	top_rte = planner_rt_fetch(top_rti, root);
+	Assert(rte->rtekind != RTE_JOIN);
+	Assert(top_rte->rtekind != RTE_JOIN);
+
+	/* Work out the correct occurrence number. */
+	for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+		AppendRelInfo *appinfo;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 *
+		 * NB: append_rel_array can be NULL if there are no children
+		 */
+		if (root->append_rel_array != NULL &&
+			(appinfo = root->append_rel_array[prior_rti]) != NULL)
+		{
+			RangeTblEntry *parent_rte;
+
+			parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+			if (parent_rte->rtekind == RTE_RELATION)
+				continue;
+		}
+
+		/* Skip NULL entries and joins. */
+		prior_rte = planner_rt_fetch(prior_rti, root);
+		if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	/* If this is a child table, get the schema and relation names. */
+	if (rti != top_rti)
+	{
+		partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+		partrel = get_rel_name(rte->relid);
+	}
+
+	/* OK, we have all the answers we need. Return them to the caller. */
+	rid->alias_name = top_rte->eref->aliasname;
+	rid->occurrence = occurrence;
+	rid->partnsp = partnsp;
+	rid->partrel = partrel;
+	rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+								   pgpa_identifier *rids)
+{
+	int			count = 0;
+	int			rti = -1;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = planner_rt_fetch(rti, root);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+		pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+	}
+
+	Assert(count > 0);
+	return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+	Index		rtable_length = list_length(pstmt->rtable);
+	pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+	Index	   *top_rti_map;
+	int			rtinfoindex = 0;
+	SubPlanRTInfo *rtinfo = NULL;
+	SubPlanRTInfo *nextrtinfo = NULL;
+
+	/*
+	 * Account for relations addded by inheritance expansion of partitioned
+	 * tables.
+	 */
+	top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+										  pstmt->appendRelations);
+
+	/*
+	 * When we begin iterating, we're processing the portion of the range
+	 * table that originated from the top-level PlannerInfo, so subrtinfo is
+	 * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+	 * portion of the range table we are processing. nextrtinfo is always the
+	 * SubPlanRTInfo that follows the current one, if any, so when we're
+	 * processing the top-level query's portion of the range table, the next
+	 * SubPlanRTInfo is the very first one.
+	 */
+	if (pstmt->subrtinfos != NULL)
+		nextrtinfo = linitial(pstmt->subrtinfos);
+
+	/* Main loop over the range table. */
+	for (Index rti = 1; rti <= rtable_length; rti++)
+	{
+		const char *plan_name;
+		Index		top_rti;
+		RangeTblEntry *rte;
+		RangeTblEntry *top_rte;
+		char	   *partnsp = NULL;
+		char	   *partrel = NULL;
+		int			occurrence;
+		pgpa_identifier *rid;
+
+		/*
+		 * Advance to the next SubPlanRTInfo, if it's time to do that.
+		 *
+		 * This loop probably shouldn't ever iterate more than once, because
+		 * that would imply that a subquery was planned but added nothing to
+		 * the range table; but let's be defensive and assume it can happen.
+		 */
+		while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+		{
+			rtinfo = nextrtinfo;
+			if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+				nextrtinfo = NULL;
+			else
+				nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+		}
+
+		/* Fetch the range table entry, if any. */
+		rte = rt_fetch(rti, pstmt->rtable);
+
+		/*
+		 * We can't and don't need to identify null entries, and we don't want
+		 * to identify join entries.
+		 */
+		if (rte == NULL || rte->rtekind == RTE_JOIN)
+			continue;
+
+		/*
+		 * If this is not a relation added by partitioned table expansion,
+		 * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+		 * we need the information for the top RTI/RTE, and must also fetch
+		 * the partition schema and name.
+		 */
+		top_rti = top_rti_map[rti - 1];
+		if (rti == top_rti)
+			top_rte = rte;
+		else
+		{
+			top_rte = rt_fetch(top_rti, pstmt->rtable);
+			partnsp =
+				get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+			partrel = get_rel_name(rte->relid);
+		}
+
+		/* Compute the correct occurrence number. */
+		occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+											rtinfo, top_rti);
+
+		/* Get the name of the current plan (NULL for toplevel query). */
+		plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+		/* Save all the details we've derived. */
+		rid = &result[rti - 1];
+		rid->alias_name = top_rte->eref->aliasname;
+		rid->occurrence = occurrence;
+		rid->partnsp = partnsp;
+		rid->partrel = partrel;
+		rid->plan_name = plan_name;
+	}
+
+	return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid)
+{
+	Index		result = 0;
+
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+	{
+		pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+		/* If there's no identifier for this RTI, skip it. */
+		if (rti_rid->alias_name == NULL)
+			continue;
+
+		/*
+		 * If it matches, return this RTI. As usual, an omitted partition
+		 * schema matches anything, but partition and plan names must either
+		 * match exactly or be omitted on both sides.
+		 */
+		if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+			rid->occurrence == rti_rid->occurrence &&
+			(rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+			 strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+			strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+			strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+		{
+			if (result != 0)
+			{
+				/* Multiple matches were found. */
+				return 0;
+			}
+			result = rti;
+		}
+	}
+
+	return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+	Index	   *top_rti_map = palloc0_array(Index, rtable_length);
+
+	/* Initially, make every RTI point to itself. */
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+		top_rti_map[rti - 1] = rti;
+
+	/* Update the map for each AppendRelInfo object. */
+	foreach_node(AppendRelInfo, appinfo, appinfos)
+	{
+		Index		parent_rti = appinfo->parent_relid;
+		RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+		/* If the parent is not RTE_RELATION, ignore this entry. */
+		if (parent_rte->rtekind != RTE_RELATION)
+			continue;
+
+		/*
+		 * Map the child to wherever we mapped the parent. Parents always
+		 * precede their children in the AppendRelInfo list, so this should
+		 * work out.
+		 */
+		top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+	}
+
+	return top_rti_map;
+}
+
+/*
+ * Find the occurence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+					   SubPlanRTInfo *rtinfo, Index rti)
+{
+	Index		rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+	int			occurrence = 1;
+	RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+	for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 */
+		if (top_rti_map[prior_rti - 1] != prior_rti)
+			break;
+
+		/* Skip joins. */
+		prior_rte = rt_fetch(prior_rti, rtable);
+		if (prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	return occurrence;
+}
diff --git a/contrib/pg_plan_advice/pgpa_identifier.h b/contrib/pg_plan_advice/pgpa_identifier.h
new file mode 100644
index 00000000000..b000d2b7081
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ *	  create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+	const char *alias_name;
+	int			occurrence;
+	const char *partnsp;
+	const char *partrel;
+	const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+	if (a == b)
+		return true;
+	else if (a == NULL || b == NULL)
+		return false;
+	else
+		return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+										   pgpa_identifier *rid);
+extern int	pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+											   Bitmapset *relids,
+											   pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+											  pgpa_identifier *rt_identifiers,
+											  pgpa_identifier *rid);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
new file mode 100644
index 00000000000..88f5327886f
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -0,0 +1,615 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	Plan	   *outer_subplan;
+	ElidedNode *outer_elided_node;
+	bool		outer_beneath_any_gather;
+	pgpa_join_strategy *strategy;
+	Plan	  **inner_subplans;
+	ElidedNode **inner_elided_nodes;
+	pgpa_join_unroller **inner_unrollers;
+	bool	   *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+											  Plan *plan,
+											  Plan **realouter,
+											  Plan **realinner,
+											  ElidedNode **elidedrealouter,
+											  ElidedNode **elidedrealinner,
+											  bool *found_any_outer_gather,
+											  bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+										   bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+									ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+	pgpa_join_unroller *join_unroller;
+
+	join_unroller = palloc0_object(pgpa_join_unroller);
+	join_unroller->nallocated = 4;
+	join_unroller->strategy =
+		palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+	join_unroller->inner_subplans =
+		palloc_array(Plan *, join_unroller->nallocated);
+	join_unroller->inner_elided_nodes =
+		palloc_array(ElidedNode *, join_unroller->nallocated);
+	join_unroller->inner_unrollers =
+		palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+	join_unroller->inner_beneath_any_gather =
+		palloc_array(bool, join_unroller->nallocated);
+
+	return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+				 bool beneath_any_gather,
+				 pgpa_join_unroller *join_unroller,
+				 pgpa_join_unroller **outer_join_unroller,
+				 pgpa_join_unroller **inner_join_unroller)
+{
+	pgpa_join_strategy strategy;
+	Plan	   *realinner,
+			   *realouter;
+	ElidedNode *elidedinner,
+			   *elidedouter;
+	int			n;
+	bool		found_any_outer_gather = false;
+	bool		found_any_inner_gather = false;
+
+	Assert(join_unroller != NULL);
+
+	/*
+	 * We need to pass the join_unroller object down through certain types of
+	 * plan nodes -- anything that's considered part of the join strategy, and
+	 * any other nodes that can occur in a join tree despite not being scans
+	 * or joins.
+	 *
+	 * This includes:
+	 *
+	 * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+	 * strategy,
+	 *
+	 * (2) Gather and Gather Merge nodes, which can occur at any point in the
+	 * join tree where the planner decided to initiate parallelism,
+	 *
+	 * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+	 * or GatherMerge,
+	 *
+	 * (4) Agg and Unique nodes, which can occur when we decide to make the
+	 * nullable side of a semijoin unique and then join the result, and
+	 *
+	 * (5) Result nodes with children, which can be added either to project to
+	 * enforce a one-time filter (but Result nodes without children are
+	 * degenerate scans or joins).
+	 */
+	if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+		|| IsA(plan, Gather) || IsA(plan, GatherMerge)
+		|| is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+		|| is_result_node_with_child(plan))
+	{
+		*outer_join_unroller = join_unroller;
+		return;
+	}
+
+	/*
+	 * Since we've already handled nodes that require pass-through treatment,
+	 * this should be an unrollable join.
+	 */
+	strategy = pgpa_decompose_join(walker, plan,
+								   &realouter, &realinner,
+								   &elidedouter, &elidedinner,
+								   &found_any_outer_gather,
+								   &found_any_inner_gather);
+
+	/* If our workspace is full, expand it. */
+	if (join_unroller->nused >= join_unroller->nallocated)
+	{
+		join_unroller->nallocated *= 2;
+		join_unroller->strategy =
+			repalloc_array(join_unroller->strategy,
+						   pgpa_join_strategy,
+						   join_unroller->nallocated);
+		join_unroller->inner_subplans =
+			repalloc_array(join_unroller->inner_subplans,
+						   Plan *,
+						   join_unroller->nallocated);
+		join_unroller->inner_elided_nodes =
+			repalloc_array(join_unroller->inner_elided_nodes,
+						   ElidedNode *,
+						   join_unroller->nallocated);
+		join_unroller->inner_beneath_any_gather =
+			repalloc_array(join_unroller->inner_beneath_any_gather,
+						   bool,
+						   join_unroller->nallocated);
+		join_unroller->inner_unrollers =
+			repalloc_array(join_unroller->inner_unrollers,
+						   pgpa_join_unroller *,
+						   join_unroller->nallocated);
+	}
+
+	/*
+	 * Since we're flattening outer-deep join trees, it follows that if the
+	 * outer side is still an unrollable join, it should be unrolled into this
+	 * same object. Otherwise, we've reached the limit of what we can unroll
+	 * into this object and must remember the outer side as the final outer
+	 * subplan.
+	 */
+	if (elidedouter == NULL && pgpa_is_join(realouter))
+		*outer_join_unroller = join_unroller;
+	else
+	{
+		join_unroller->outer_subplan = realouter;
+		join_unroller->outer_elided_node = elidedouter;
+		join_unroller->outer_beneath_any_gather =
+			beneath_any_gather || found_any_outer_gather;
+	}
+
+	/*
+	 * Store the inner subplan. If it's an unrollable join, it needs to be
+	 * flattened in turn, but into a new unroller object, not this one.
+	 */
+	n = join_unroller->nused++;
+	join_unroller->strategy[n] = strategy;
+	join_unroller->inner_subplans[n] = realinner;
+	join_unroller->inner_elided_nodes[n] = elidedinner;
+	join_unroller->inner_beneath_any_gather[n] =
+		beneath_any_gather || found_any_inner_gather;
+	if (elidedinner == NULL && pgpa_is_join(realinner))
+		*inner_join_unroller = pgpa_create_join_unroller();
+	else
+		*inner_join_unroller = NULL;
+	join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+						 pgpa_join_unroller *join_unroller)
+{
+	pgpa_unrolled_join *ujoin;
+	int			i;
+
+	/*
+	 * We shouldn't have gone even so far as to create a join unroller unless
+	 * we found at least one unrollable join.
+	 */
+	Assert(join_unroller->nused > 0);
+
+	/* Allocate result structures. */
+	ujoin = palloc0_object(pgpa_unrolled_join);
+	ujoin->ninner = join_unroller->nused;
+	ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+	ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+	/* Handle the outermost join. */
+	ujoin->outer.plan = join_unroller->outer_subplan;
+	ujoin->outer.elided_node = join_unroller->outer_elided_node;
+	ujoin->outer.scan =
+		pgpa_build_scan(walker, ujoin->outer.plan,
+						ujoin->outer.elided_node,
+						join_unroller->outer_beneath_any_gather,
+						true);
+
+	/*
+	 * We want the joins from the deepest part of the plan tree to appear
+	 * first in the result object, but the join unroller adds them in exactly
+	 * the reverse of that order, so we need to flip the order of the arrays
+	 * when constructing the final result.
+	 */
+	for (i = 0; i < join_unroller->nused; ++i)
+	{
+		int			k = join_unroller->nused - i - 1;
+
+		/* Copy strategy, Plan, and ElidedNode. */
+		ujoin->strategy[i] = join_unroller->strategy[k];
+		ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+		ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+		/*
+		 * Fill in remaining details, using either the nested join unroller,
+		 * or by deriving them from the plan and elided nodes.
+		 */
+		if (join_unroller->inner_unrollers[k] != NULL)
+			ujoin->inner[i].unrolled_join =
+				pgpa_build_unrolled_join(walker,
+										 join_unroller->inner_unrollers[k]);
+		else
+			ujoin->inner[i].scan =
+				pgpa_build_scan(walker, ujoin->inner[i].plan,
+								ujoin->inner[i].elided_node,
+								join_unroller->inner_beneath_any_gather[i],
+								true);
+	}
+
+	return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+	pfree(join_unroller->strategy);
+	pfree(join_unroller->inner_subplans);
+	pfree(join_unroller->inner_elided_nodes);
+	pfree(join_unroller->inner_unrollers);
+	pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort note on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any correspoding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+					Plan **realouter, Plan **realinner,
+					ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+					bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+	PlannedStmt *pstmt = walker->pstmt;
+	JoinType	jointype = ((Join *) plan)->jointype;
+	Plan	   *outerplan = plan->lefttree;
+	Plan	   *innerplan = plan->righttree;
+	ElidedNode *elidedouter;
+	ElidedNode *elidedinner;
+	pgpa_join_strategy strategy;
+	bool		uniqueouter;
+	bool		uniqueinner;
+
+	elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+	elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+	*found_any_outer_gather = false;
+	*found_any_inner_gather = false;
+
+	switch (nodeTag(plan))
+	{
+		case T_MergeJoin:
+
+			/*
+			 * The planner may have chosen to place a Material node on the
+			 * inner side of the MergeJoin; if this is present, we record it
+			 * as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+			}
+			else
+				strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+			/*
+			 * For a MergeJoin, either the outer or the inner subplan, or
+			 * both, may have needed to be sorted; we must disregard any Sort
+			 * or IncrementalSort node to find the real inner or outer
+			 * subplan.
+			 */
+			if (elidedouter == NULL && is_sorting_plan(outerplan))
+				elidedouter = pgpa_descend_node(pstmt, &outerplan);
+			if (elidedinner == NULL && is_sorting_plan(innerplan))
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			break;
+
+		case T_NestLoop:
+
+			/*
+			 * The planner may have chosen to place a Material or Memoize node
+			 * on the inner side of the NestLoop; if this is present, we
+			 * record it as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+			}
+			else if (elidedinner == NULL && IsA(innerplan, Memoize))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+			}
+			else
+				strategy = JSTRAT_NESTED_LOOP_PLAIN;
+			break;
+
+		case T_HashJoin:
+
+			/*
+			 * The inner subplan of a HashJoin is always a Hash node; the real
+			 * inner subplan is the Hash node's child.
+			 */
+			Assert(IsA(innerplan, Hash));
+			Assert(elidedinner == NULL);
+			elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			strategy = JSTRAT_HASH_JOIN;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+	}
+
+	/*
+	 * The planner may have decided to implement a semijoin by first making
+	 * the nullable side of the plan unique, and then performing a normal join
+	 * against the result. Therefore, we might need to descend through a
+	 * unique node on either side of the plan.
+	 */
+	uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+	uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+	/*
+	 * The planner may have decided to parallelize part of the join tree, so
+	 * we could find a Gather or Gather Merge node here. Note that, if
+	 * present, this will appear below nodes we considered as part of the join
+	 * strategy, but we could find another uniqueness-enforcing node below the
+	 * Gather or Gather Merge, if present.
+	 */
+	if (elidedouter == NULL)
+	{
+		elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+											  found_any_outer_gather);
+		if (found_any_outer_gather &&
+			pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+			uniqueouter = true;
+	}
+	if (elidedinner == NULL)
+	{
+		elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+											  found_any_inner_gather);
+		if (found_any_inner_gather &&
+			pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+			uniqueinner = true;
+	}
+
+	/*
+	 * It's possible that Result node has been inserted either to project a
+	 * target list or to implement a one-time filter. If so, we can descend
+	 * throught it. Note that a result node without a child would be a
+	 * degenerate scan or join, and not something we could descend through.
+	 *
+	 * XXX. I suspect it's possible for this to happen above the Gather or
+	 * Gather Merge node, too, but apparently we have no test case for that
+	 * scenario.
+	 */
+	if (elidedouter == NULL && is_result_node_with_child(outerplan))
+		elidedouter = pgpa_descend_node(pstmt, &outerplan);
+	if (elidedinner == NULL && is_result_node_with_child(innerplan))
+		elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+	/*
+	 * If this is a semijoin that was converted to an inner join by making one
+	 * side or the other unique, make a note that the inner or outer subplan,
+	 * as appropriate, should be treated as a query plan feature when the main
+	 * tree traversal reaches it.
+	 *
+	 * Conversely, if the planner could have made one side of the join unique
+	 * and thereby converted it to an inner join, and chose not to do so, that
+	 * is also worth noting.
+	 *
+	 * XXX: We admit too much non-unique advice, as in the following example
+	 * from the regression tests: EXPLAIN (PLAN_ADVICE, COSTS OFF) DELETE FROM
+	 * prt1_l WHERE EXISTS (SELECT 1 FROM int4_tbl, LATERAL (SELECT
+	 * int4_tbl.f1 FROM int8_tbl LIMIT 2) ss WHERE prt1_l.c IS NULL). We emit
+	 * SEMIJOIN_NON_UNIQUE((int4_tbl ss)) but create_unique_path() fails in
+	 * this case, so there's no sj-unique version possible.
+	 *
+	 * NB: This code could appear slightly higher up in in this function, but
+	 * none of the nodes through which we just descended should have
+	 * associated RTIs.
+	 *
+	 * NB: This seems like a somewhat hacky way of passing information up to
+	 * the main tree walk, but I don't currently have a better idea.
+	 */
+	if (uniqueouter)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+	else if (jointype == JOIN_RIGHT_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+	if (uniqueinner)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+	else if (jointype == JOIN_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+	/* Set output parameters. */
+	*realouter = outerplan;
+	*realinner = innerplan;
+	*elidedrealouter = elidedouter;
+	*elidedrealinner = elidedinner;
+	return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+	*plan = (*plan)->lefttree;
+	return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+						bool *found_any_gather)
+{
+	if (IsA(*plan, Gather))
+	{
+		*found_any_gather = true;
+		return pgpa_descend_node(pstmt, plan);
+	}
+
+	if (IsA(*plan, GatherMerge))
+	{
+		ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+		if (elided == NULL && is_sorting_plan(*plan))
+			elided = pgpa_descend_node(pstmt, plan);
+
+		*found_any_gather = true;
+		return elided;
+	}
+
+	return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through at least one node, and
+ * otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+						ElidedNode **elided_node)
+{
+	if (*elided_node != NULL)
+		return false;
+
+	if (IsA(*plan, Agg) || IsA(*plan, Unique))
+	{
+		*elided_node = pgpa_descend_node(pstmt, plan);
+
+		if (*elided_node == NULL && is_sorting_plan(*plan))
+			*elided_node = pgpa_descend_node(pstmt, plan);
+
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+	return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+	return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
diff --git a/contrib/pg_plan_advice/pgpa_join.h b/contrib/pg_plan_advice/pgpa_join.h
new file mode 100644
index 00000000000..4dc72986a70
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.h
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+	JSTRAT_MERGE_JOIN_PLAIN = 0,
+	JSTRAT_MERGE_JOIN_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_PLAIN,
+	JSTRAT_NESTED_LOOP_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_MEMOIZE,
+	JSTRAT_HASH_JOIN
+	/* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY		((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+	Plan	   *plan;
+	ElidedNode *elided_node;
+	struct pgpa_scan *scan;
+	pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>.  When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+	/* Outermost member; must not itself be an unrolled join. */
+	pgpa_join_member outer;
+
+	/* Number of inner members. Length of the strategy and inner arrays. */
+	unsigned	ninner;
+
+	/* Array of strategies, one per non-outermost member. */
+	pgpa_join_strategy *strategy;
+
+	/* Array of members, excluding the outermost. Deepest first. */
+	pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+	return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+							 Plan *plan, bool beneath_any_gather,
+							 pgpa_join_unroller *join_unroller,
+							 pgpa_join_unroller **outer_join_unroller,
+							 pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+													pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
new file mode 100644
index 00000000000..89a675ff93e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -0,0 +1,628 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+	const char **rid_strings;
+	StringInfo	buf;
+	int			wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+									  pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+									pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+									  pgpa_scan_strategy strategy,
+									  List *scans);
+static void pgpa_output_bitmap_index_details(pgpa_output_context *context,
+											 Plan *plan);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+									  pgpa_qf_type type,
+									  List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+										char *strategy,
+										List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+								  Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+								  Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+				   pgpa_identifier *rt_identifiers)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	ListCell   *lc;
+	pgpa_output_context context;
+
+	/* Basic initialization. */
+	memset(&context, 0, sizeof(pgpa_output_context));
+	context.buf = buf;
+
+	/*
+	 * Convert identifiers to string form. Note that the loop variable here is
+	 * not an RTI, because RTIs are 1-based. Some RTIs will have no
+	 * identifier, either because the reloptkind is RTE_JOIN or because that
+	 * portion of the query didn't make it into the final plan.
+	 */
+	context.rid_strings = palloc0_array(const char *, rtable_length);
+	for (int i = 0; i < rtable_length; ++i)
+		if (rt_identifiers[i].alias_name != NULL)
+			context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+	/*
+	 * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+	 * from a psql client with default settings, psql will add one space to
+	 * the left of the output and EXPLAIN will add two more to the left of the
+	 * advice. Thus, lines of more than 77 characters will wrap. We set the
+	 * wrap limit to 76 here so that the output won't reach all the way to the
+	 * very last column of the terminal.
+	 *
+	 * Of course, this is fairly arbitrary set of assumptions, and one could
+	 * well make an argument for a different wrap limit, or for a configurable
+	 * one.
+	 */
+	context.wrap_column = 76;
+
+	/*
+	 * Each piece of JOIN_ORDER() advice fully describes the join order for a
+	 * a single unrolled join. Merging is not permitted, because that would
+	 * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+	 * scans should be used for all of those relations, and is thus equivalent
+	 * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+	 * is the driving table which is then joined to "b" then "c" then "d",
+	 * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+	 */
+	foreach(lc, walker->toplevel_unrolled_joins)
+	{
+		pgpa_unrolled_join *ujoin = lfirst(lc);
+
+		if (buf->len > 0)
+			appendStringInfoChar(buf, '\n');
+		appendStringInfo(context.buf, "JOIN_ORDER(");
+		pgpa_output_unrolled_join(&context, ujoin);
+		appendStringInfoChar(context.buf, ')');
+		pgpa_maybe_linebreak(context.buf, context.wrap_column);
+	}
+
+	/* Emit join strategy advice. */
+	for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+	{
+		char	   *strategy = pgpa_cstring_join_strategy(s);
+
+		pgpa_output_simple_strategy(&context,
+									strategy,
+									walker->join_strategies[s]);
+	}
+
+	/*
+	 * Emit scan strategy advice (but not for ordinary scans, which are
+	 * definitionally uninteresting).
+	 */
+	for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+		if (c != PGPA_SCAN_ORDINARY)
+			pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+	/* Emit query feature advice. */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+		pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+	/* Emit NO_GATHER advice. */
+	pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+						  pgpa_unrolled_join *join)
+{
+	pgpa_output_join_member(context, &join->outer);
+
+	for (int k = 0; k < join->ninner; ++k)
+	{
+		pgpa_join_member *member = &join->inner[k];
+
+		pgpa_maybe_linebreak(context->buf, context->wrap_column);
+		appendStringInfoChar(context->buf, ' ');
+		pgpa_output_join_member(context, member);
+	}
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+						pgpa_join_member *member)
+{
+	if (member->unrolled_join != NULL)
+	{
+		appendStringInfoChar(context->buf, '(');
+		pgpa_output_unrolled_join(context, member->unrolled_join);
+		appendStringInfoChar(context->buf, ')');
+	}
+	else
+	{
+		pgpa_scan  *scan = member->scan;
+
+		Assert(scan != NULL);
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '{');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, '}');
+		}
+	}
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+						  pgpa_scan_strategy strategy,
+						  List *scans)
+{
+	bool		first = true;
+
+	if (scans == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_scan_strategy(strategy));
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		Plan	   *plan = scan->plan;
+
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		/* Output the relation identifiers. */
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+
+		/* For scans involving indexes, output index information. */
+		if (strategy == PGPA_SCAN_INDEX)
+		{
+			Assert(IsA(plan, IndexScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_INDEX_ONLY)
+		{
+			Assert(IsA(plan, IndexOnlyScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context,
+									  ((IndexOnlyScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_BITMAP_HEAP)
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_bitmap_index_details(context, plan->lefttree);
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output information about which index or indexes power a BitmapHeapScan.
+ *
+ * We emit &&(i1 i2 i3) for a BitmapAnd between indexes i1, i2, and i3;
+ * and likewise ||(i1 i2 i3) for a similar BitmapOr operation.
+ */
+static void
+pgpa_output_bitmap_index_details(pgpa_output_context *context, Plan *plan)
+{
+	char	   *operator;
+	List	   *bitmapplans;
+	bool		first = true;
+
+	if (IsA(plan, BitmapIndexScan))
+	{
+		BitmapIndexScan *bitmapindexscan = (BitmapIndexScan *) plan;
+
+		pgpa_output_relation_name(context, bitmapindexscan->indexid);
+		return;
+	}
+
+	if (IsA(plan, BitmapOr))
+	{
+		operator = "||";
+		bitmapplans = ((BitmapOr *) plan)->bitmapplans;
+	}
+	else if (IsA(plan, BitmapAnd))
+	{
+		operator = "&&";
+		bitmapplans = ((BitmapAnd *) plan)->bitmapplans;
+	}
+	else
+		elog(ERROR, "unexpected node type: %d", (int) nodeTag(plan));
+
+	appendStringInfo(context->buf, "%s(", operator);
+	foreach_ptr(Plan, child_plan, bitmapplans)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+		pgpa_output_bitmap_index_details(context, child_plan);
+	}
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+	Oid			nspoid = get_rel_namespace(relid);
+	char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+	char	   *relname = get_rel_name(relid);
+
+	appendStringInfoString(context->buf, quote_identifier(relnamespace));
+	appendStringInfoChar(context->buf, '.');
+	appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+						  List *query_features)
+{
+	bool		first = true;
+
+	if (query_features == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_query_feature_type(type));
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(qf->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, qf->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, qf->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+							List *relid_sets)
+{
+	bool		first = true;
+
+	if (relid_sets == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(", strategy);
+
+	foreach_node(Bitmapset, relids, relid_sets)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+	if (relids == NULL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "NO_GATHER(");
+	pgpa_output_relations(context, context->buf, relids);
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+					  Bitmapset *relids)
+{
+	int			rti = -1;
+	bool		first = true;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		const char *rid_string = context->rid_strings[rti - 1];
+
+		if (rid_string == NULL)
+			elog(ERROR, "no identifier for RTI %d", rti);
+
+		if (first)
+		{
+			first = false;
+			appendStringInfoString(buf, rid_string);
+		}
+		else
+		{
+			pgpa_maybe_linebreak(buf, context->wrap_column);
+			appendStringInfo(buf, " %s", rid_string);
+		}
+	}
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+	switch (strategy)
+	{
+		case JSTRAT_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case JSTRAT_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case JSTRAT_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case JSTRAT_HASH_JOIN:
+			return "HASH_JOIN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+	switch (strategy)
+	{
+		case PGPA_SCAN_ORDINARY:
+			return "ORDINARY_SCAN";
+		case PGPA_SCAN_SEQ:
+			return "SEQ_SCAN";
+		case PGPA_SCAN_BITMAP_HEAP:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_SCAN_FOREIGN:
+			return "FOREIGN_JOIN";
+		case PGPA_SCAN_INDEX:
+			return "INDEX_SCAN";
+		case PGPA_SCAN_INDEX_ONLY:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_SCAN_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_SCAN_TID:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+	switch (type)
+	{
+		case PGPAQF_GATHER:
+			return "GATHER";
+		case PGPAQF_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPAQF_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPAQF_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+	}
+
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+	char	   *trailing_nl;
+	int			line_start;
+	int			save_cursor;
+
+	/* If line wrapping is disabled, exit quickly. */
+	if (wrap_column <= 0)
+		return;
+
+	/*
+	 * Set line_start to the byte offset within buf->data of the first
+	 * character of the current line, where the current line means the last
+	 * one in the buffer. Note that line_start could be the offset of the
+	 * trailing '\0' if the last character in the buffer is a line break.
+	 */
+	trailing_nl = strrchr(buf->data, '\n');
+	if (trailing_nl == NULL)
+		line_start = 0;
+	else
+		line_start = (trailing_nl - buf->data) + 1;
+
+	/*
+	 * Remember that the current end of the buffer is a potential location to
+	 * insert a line break on a future call to this function.
+	 */
+	save_cursor = buf->cursor;
+	buf->cursor = buf->len;
+
+	/* If we haven't passed the wrap column, we don't need a newline. */
+	if (buf->len - line_start <= wrap_column)
+		return;
+
+	/*
+	 * It only makes sense to insert a newline at a position later than the
+	 * beginning of the current line.
+	 */
+	if (buf->cursor <= line_start)
+		return;
+
+	/* Insert a newline at the previous cursor location. */
+	enlargeStringInfo(buf, 1);
+	memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+			buf->len - save_cursor);
+	++buf->cursor;
+	buf->data[++buf->len] = '\0';
+	buf->data[save_cursor] = '\n';
+}
diff --git a/contrib/pg_plan_advice/pgpa_output.h b/contrib/pg_plan_advice/pgpa_output.h
new file mode 100644
index 00000000000..47496d76f52
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+							   pgpa_plan_walker_context *walker,
+							   pgpa_identifier *rt_identifiers);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_parser.y b/contrib/pg_plan_advice/pgpa_parser.y
new file mode 100644
index 00000000000..4617e7f2f64
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_parser.y
@@ -0,0 +1,337 @@
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc.  This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE   pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+	char	   *str;
+	int			integer;
+	List	   *list;
+	pgpa_advice_item *item;
+	pgpa_advice_target *target;
+	pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_BITMAP TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+%token TOK_OR TOK_AND
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list bitmap_sublist bitmap_target_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> bitmap_target_item index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+		{
+			(void) yynerrs;				/* suppress compiler warning */
+			*result = $1;
+		}
+	;
+
+advice_item_list: advice_item_list advice_item
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_JOIN_ORDER;
+			$$->targets = $3;
+		}
+	| TOK_TAG_INDEX '(' index_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "index_only_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+			else if (strcmp($1, "index_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_BITMAP '(' bitmap_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+			$$->targets = $3;
+		}
+	| TOK_TAG_SIMPLE '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "no_gather") == 0)
+				$$->tag = PGPA_TAG_NO_GATHER;
+			else if (strcmp($1, "seq_scan") == 0)
+				$$->tag = PGPA_TAG_SEQ_SCAN;
+			else if (strcmp($1, "tid_scan") == 0)
+				$$->tag = PGPA_TAG_TID_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_GENERIC '(' generic_target_list ')'
+		{
+			bool	fail;
+
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = pgpa_parse_advice_tag($1, &fail);
+			if (fail)
+			{
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "unrecognized advice tag");
+			}
+
+			if ($$->tag == PGPA_TAG_FOREIGN_JOIN)
+			{
+				foreach_ptr(pgpa_advice_target, target, $3)
+				{
+					if (target->ttype == PGPA_TARGET_IDENTIFIER ||
+						list_length(target->children) == 1)
+							pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+										 "FOREIGN_JOIN targets must contain more than one relation identifier");
+				}
+			}
+
+			$$->targets = $3;
+		}
+	;
+
+relation_identifier: identifier opt_ri_occurrence opt_partition opt_plan_name
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_IDENTIFIER;
+			$$->rid.alias_name = $1;
+			$$->rid.occurrence = $2;
+			if (list_length($3) == 2)
+			{
+				$$->rid.partnsp = linitial($3);
+				$$->rid.partrel = lsecond($3);
+			}
+			else if ($3 != NIL)
+				$$->rid.partrel = linitial($3);
+			$$->rid.plan_name = $4;
+		}
+	;
+
+index_name: identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indname = $1;
+		}
+	| identifier '.' identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indnamespace = $1;
+			$$->indname = $3;
+		}
+	;
+
+opt_ri_occurrence:
+	'#' TOK_INTEGER
+		{
+			if ($2 <= 0)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "only positive occurrence numbers are permitted");
+			$$ = $2;
+		}
+	|
+		{
+			/* The default occurrence number is 1. */
+			$$ = 1;
+		}
+	;
+
+identifier: TOK_IDENT
+	| TOK_TAG_JOIN_ORDER
+	| TOK_TAG_INDEX
+	| TOK_TAG_BITMAP
+	| TOK_TAG_SIMPLE
+	| TOK_TAG_GENERIC
+	;
+
+/*
+ * When generating advice, we always schema-qualify the partition name, but
+ * when parsing advice, we accept a specification that lacks one.
+ */
+opt_partition:
+	'/' TOK_IDENT '.' TOK_IDENT
+		{ $$ = list_make2($2, $4); }
+	| '/' TOK_IDENT
+		{ $$ = list_make1($2); }
+	|
+		{ $$ = NIL; }
+	;
+
+opt_plan_name:
+	'@' TOK_IDENT
+		{ $$ = $2; }
+	|
+		{ $$ = NULL; }
+	;
+
+bitmap_target_list: bitmap_target_list relation_identifier bitmap_target_item
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+bitmap_target_item: index_name
+		{ $$ = $1; }
+	| TOK_OR '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_OR;
+			$$->children = $3;
+		}
+	| TOK_AND '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_AND;
+			$$->children = $3;
+		}
+	;
+
+bitmap_sublist: bitmap_sublist bitmap_target_item
+		{ $$ = lappend($1, $2); }
+	| bitmap_target_item
+		{ $$ = list_make1($1); }
+	;
+
+generic_target_list: generic_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| generic_target_list generic_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+generic_sublist: '(' generic_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+index_target_list:
+	  index_target_list relation_identifier index_name
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_target_list: join_order_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| join_order_target_list join_order_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_sublist:
+	'(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	| '{' simple_target_list '}'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_UNORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+simple_target_list: simple_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+%%
+
+/*
+ * Parse an advice_string and return the resulting list of pgpa_advice_item
+ * objects. If a parse error occurs, instead return NULL.
+ *
+ * If the return value is NULL, *error_p will be set to the error message;
+ * otherwise, *error_p will be set to NULL.
+ */
+List *
+pgpa_parse(const char *advice_string, char **error_p)
+{
+	yyscan_t	scanner;
+	List	   *result;
+	char	   *error = NULL;
+
+	pgpa_scanner_init(advice_string, &scanner);
+	pgpa_yyparse(&result, &error, scanner);
+	pgpa_scanner_finish(scanner);
+
+	if (error != NULL)
+	{
+		*error_p = error;
+		return NULL;
+	}
+
+	*error_p = NULL;
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
new file mode 100644
index 00000000000..bf1eda3b8f7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -0,0 +1,1706 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.c
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "common/hashfn_unstable.h"
+#include "nodes/makefuncs.h"
+#include "optimizer/extendplan.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/plancat.h"
+#include "optimizer/planner.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * When assertions are enabled, we try generating relation identifiers during
+ * planning, saving them in a hash table, and then cross-checking them against
+ * the ones generated after planning is complete.
+ */
+typedef struct pgpa_ri_checker_key
+{
+	char	   *plan_name;
+	Index		rti;
+} pgpa_ri_checker_key;
+
+typedef struct pgpa_ri_checker
+{
+	pgpa_ri_checker_key key;
+	uint32		status;
+	const char *rid_string;
+} pgpa_ri_checker;
+
+static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
+
+static inline bool
+pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
+{
+	if (a.rti != b.rti)
+		return false;
+	if (a.plan_name == NULL)
+		return (b.plan_name == NULL);
+	if (b.plan_name == NULL)
+		return false;
+	return strcmp(a.plan_name, b.plan_name) == 0;
+}
+
+#define SH_PREFIX			pgpa_ri_check
+#define SH_ELEMENT_TYPE		pgpa_ri_checker
+#define SH_KEY_TYPE			pgpa_ri_checker_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#endif
+
+typedef struct pgpa_planner_state
+{
+	ExplainState *explain_state;
+	pgpa_trove *trove;
+	MemoryContext trove_cxt;
+
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_check_hash *ri_check_hash;
+#endif
+} pgpa_planner_state;
+
+typedef struct pgpa_join_state
+{
+	/* Most-recently-considered outer rel. */
+	RelOptInfo *outerrel;
+
+	/* Most-recently-considered inner rel. */
+	RelOptInfo *innerrel;
+
+	/*
+	 * Array of relation identifiers for all members of this joinrel, with
+	 * outerrel idenifiers before innerrel identifiers.
+	 */
+	pgpa_identifier *rids;
+
+	/* Number of outer rel identifiers. */
+	int			outer_count;
+
+	/* Number of inner rel identifiers. */
+	int			inner_count;
+
+	/*
+	 * Trove lookup results.
+	 *
+	 * join_entries and rel_entries are arrays of entries, and join_indexes
+	 * and rel_indexes are the integer offsets within those arrays of entries
+	 * potentially relevant to us. The "join" fields correspond to a lookup
+	 * using PGPA_TROVE_LOOKUP_JOIN and the "rel" fields to a lookup using
+	 * PGPA_TROVE_LOOKUP_REL.
+	 */
+	pgpa_trove_entry *join_entries;
+	Bitmapset  *join_indexes;
+	pgpa_trove_entry *rel_entries;
+	Bitmapset  *rel_indexes;
+} pgpa_join_state;
+
+/* Saved hook values */
+static get_relation_info_hook_type prev_get_relation_info = NULL;
+static join_path_setup_hook_type prev_join_path_setup = NULL;
+static joinrel_setup_hook_type prev_joinrel_setup = NULL;
+static planner_setup_hook_type prev_planner_setup = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+
+/* Other global variabes */
+static int	planner_extension_id = -1;
+
+/* Function prototypes. */
+static void pgpa_get_relation_info(PlannerInfo *root,
+								   Oid relationObjectId,
+								   bool inhparent,
+								   RelOptInfo *rel);
+static void pgpa_joinrel_setup(PlannerInfo *root,
+							   RelOptInfo *joinrel,
+							   RelOptInfo *outerrel,
+							   RelOptInfo *innerrel,
+							   SpecialJoinInfo *sjinfo,
+							   List *restrictlist);
+static void pgpa_join_path_setup(PlannerInfo *root,
+								 RelOptInfo *joinrel,
+								 RelOptInfo *outerrel,
+								 RelOptInfo *innerrel,
+								 JoinType jointype,
+								 JoinPathExtraData *extra);
+static void pgpa_planner_setup(PlannerGlobal *glob, Query *parse,
+							   const char *query_string,
+							   double *tuple_fraction,
+							   ExplainState *es);
+static void pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string, PlannedStmt *pstmt);
+static void pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p,
+											  char *plan_name,
+											  pgpa_join_state *pjs);
+static void pgpa_planner_apply_join_path_advice(JoinType jointype,
+												uint64 *pgs_mask_p,
+												char *plan_name,
+												pgpa_join_state *pjs);
+static void pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+										   pgpa_trove_entry *scan_entries,
+										   Bitmapset *scan_indexes,
+										   pgpa_trove_entry *rel_entries,
+										   Bitmapset *rel_indexes);
+static uint64 pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag);
+static bool pgpa_join_order_permits_join(int outer_count, int inner_count,
+										 pgpa_identifier *rids,
+										 pgpa_trove_entry *entry);
+static bool pgpa_join_method_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+
+static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+										  pgpa_trove_lookup_type type,
+										  pgpa_identifier *rt_identifiers,
+										  pgpa_plan_walker_context *walker);
+
+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);
+
+/*
+ * Install planner-related hooks.
+ */
+void
+pgpa_planner_install_hooks(void)
+{
+	planner_extension_id = GetPlannerExtensionId("pg_plan_advice");
+	prev_get_relation_info = get_relation_info_hook;
+	get_relation_info_hook = pgpa_get_relation_info;
+	prev_joinrel_setup = joinrel_setup_hook;
+	joinrel_setup_hook = pgpa_joinrel_setup;
+	prev_join_path_setup = join_path_setup_hook;
+	join_path_setup_hook = pgpa_join_path_setup;
+	prev_planner_setup = planner_setup_hook;
+	planner_setup_hook = pgpa_planner_setup;
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgpa_planner_shutdown;
+}
+
+/*
+ * Hook function for get_relation_info().
+ *
+ * We can apply scan advice at this opint, and we also usee this as an
+ * opportunity to do range-table identifier cross-checking in assert-enabled
+ * builds.
+ *
+ * XXX: We currently emit useless advice like NO_GATHER("*RESULT*") for trivial
+ * queries. The advice is useless because get_relation_info isn't called for
+ * non-relation RTEs. We should either suppress the advice in such cases, or
+ * add a hook that can apply it.
+ */
+static void
+pgpa_get_relation_info(PlannerInfo *root, Oid relationObjectId,
+					   bool inhparent, RelOptInfo *rel)
+{
+	pgpa_planner_state *pps;
+
+	/* 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. */
+	if (pps != NULL)
+		pgpa_ri_checker_save(pps, root, rel);
+
+	/* If query advice was provided, search for relevant entries. */
+	if (pps != NULL && pps->trove != NULL)
+	{
+		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,
+						  &tresult_scan);
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+						  &tresult_rel);
+
+		/* If relevant entries were found, apply them. */
+		if (tresult_scan.indexes != NULL || tresult_rel.indexes != NULL)
+			pgpa_planner_apply_scan_advice(rel,
+										   tresult_scan.entries,
+										   tresult_scan.indexes,
+										   tresult_rel.entries,
+										   tresult_rel.indexes);
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_get_relation_info)
+		(*prev_get_relation_info) (root, relationObjectId, inhparent, rel);
+}
+
+/*
+ * Search for advice pertaining to a proposed join.
+ */
+static pgpa_join_state *
+pgpa_get_join_state(PlannerInfo *root, RelOptInfo *joinrel,
+					RelOptInfo *outerrel, RelOptInfo *innerrel)
+{
+	pgpa_planner_state *pps;
+	pgpa_join_state *pjs;
+	bool		new_pjs = false;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+	if (pps == NULL || pps->trove == NULL)
+	{
+		/* No advice applies to this query, hence none to this joinrel. */
+		return NULL;
+	}
+
+	/*
+	 * See whether we've previously associated a pgpa_join_state with this
+	 * joinrel. If we have not, we need to try to construct one. If we have,
+	 * then there are two cases: (a) if innerrel and outerrel are unchanged,
+	 * we can simply use it, and (b) if they have changed, we need to rejigger
+	 * the array of identifiers but can still skip the trove lookup.
+	 */
+	pjs = GetRelOptInfoExtensionState(joinrel, planner_extension_id);
+	if (pjs != NULL)
+	{
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+		{
+			/*
+			 * If there's no potentially relevant advice, then the presence of
+			 * this pgpa_join_state acts like a negative cache entry: it tells
+			 * us not to bother searching the trove for advice, because we
+			 * will not find any.
+			 */
+			return NULL;
+		}
+
+		if (pjs->outerrel == outerrel && pjs->innerrel == innerrel)
+		{
+			/* No updates required, so just return. */
+			/* XXX. Does this need to do something different under GEQO? */
+			return pjs;
+		}
+	}
+
+	/*
+	 * If there's no pgpa_join_state yet, we need to allocate one. Trove keys
+	 * will not get built for RTE_JOIN RTEs, so the array may end up being
+	 * larger than needed. It's not worth trying to compute a perfectly
+	 * accurate count here.
+	 */
+	if (pjs == NULL)
+	{
+		int			pessimistic_count = bms_num_members(joinrel->relids);
+
+		pjs = palloc0_object(pgpa_join_state);
+		pjs->rids = palloc_array(pgpa_identifier, pessimistic_count);
+		new_pjs = true;
+	}
+
+	/*
+	 * Either we just allocated a new pgpa_join_state, or the existing one
+	 * needs reconfiguring for a new innerrel and outerrel. The required array
+	 * size can't change, so we can overwrite the existing one.
+	 */
+	pjs->outerrel = outerrel;
+	pjs->innerrel = innerrel;
+	pjs->outer_count =
+		pgpa_compute_identifiers_by_relids(root, outerrel->relids, pjs->rids);
+	pjs->inner_count =
+		pgpa_compute_identifiers_by_relids(root, innerrel->relids,
+										   pjs->rids + pjs->outer_count);
+
+	/*
+	 * If we allocated a new pgpa_join_state, search our trove of advice for
+	 * relevant entries. The trove lookup will return the same results for
+	 * every outerrel/innerrel combination, so we don't need to repeat that
+	 * work every time.
+	 */
+	if (new_pjs)
+	{
+		pgpa_trove_result tresult;
+
+		/* Find join entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_JOIN,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->join_entries = tresult.entries;
+		pjs->join_indexes = tresult.indexes;
+
+		/* Find rel entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->rel_entries = tresult.entries;
+		pjs->rel_indexes = tresult.indexes;
+
+		/* Now that the new pgpa_join_state is fully valid, save a pointer. */
+		SetRelOptInfoExtensionState(joinrel, planner_extension_id, pjs);
+
+		/*
+		 * If there was no relevant advice found, just return NULL. This
+		 * pgpa_join_state will stick around as a sort of negative cache
+		 * entry, so that future calls for this same joinrel quickly return
+		 * NULL.
+		 */
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+			return NULL;
+	}
+
+	return pjs;
+}
+
+/*
+ * Enforce any provided advice that is relevant to any method of implementing
+ * this join.
+ *
+ * Although we're passed the outerrel and innerrel here, those are just
+ * whatever values happened to prompt the creation of this joinrel; they
+ * shouldn't really influence our choice of what advice to apply.
+ */
+static void
+pgpa_joinrel_setup(PlannerInfo *root, RelOptInfo *joinrel,
+				   RelOptInfo *outerrel, RelOptInfo *innerrel,
+				   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_joinrel_advice(&joinrel->pgs_mask,
+										  root->plan_name,
+										  pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_joinrel_setup)
+		(*prev_joinrel_setup) (root, joinrel, outerrel, innerrel,
+							   sjinfo, restrictlist);
+}
+
+/*
+ * Enforce any provided advice that is relevant to this particular method of
+ * implementing this particular join.
+ */
+static void
+pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
+					 RelOptInfo *outerrel, RelOptInfo *innerrel,
+					 JoinType jointype, JoinPathExtraData *extra)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_join_path_advice(jointype,
+											&extra->pgs_mask,
+											root->plan_name,
+											pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_join_path_setup)
+		(*prev_join_path_setup) (root, joinrel, outerrel, innerrel,
+								 jointype, extra);
+}
+
+/*
+ * Prepare advice for use by a query.
+ */
+static void
+pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
+				   double *tuple_fraction, ExplainState *es)
+{
+	pgpa_trove *trove = NULL;
+	pgpa_planner_state *pps;
+	bool		needs_pps = false;
+
+	/*
+	 * If any advice was provided, build a trove of advice for use during
+	 * planning.
+	 */
+	if (pg_plan_advice_advice != NULL && pg_plan_advice_advice[0] != '\0')
+	{
+		List	   *advice_items;
+		char	   *error;
+
+		/*
+		 * Parsing shouldn't fail here, because we must have previously parsed
+		 * successfully in pg_plan_advice_advice_check_hook, but if it does,
+		 * emit a warning.
+		 */
+		advice_items = pgpa_parse(pg_plan_advice_advice, &error);
+		if (error)
+			elog(WARNING, "could not parse advice: %s", error);
+
+		/*
+		 * It's possible that the advice string was non-empty but contained no
+		 * actual advice, e.g. it was all whitespace.
+		 */
+		if (advice_items != NIL)
+		{
+			trove = pgpa_build_trove(advice_items);
+			needs_pps = true;
+		}
+	}
+
+#ifdef USE_ASSERT_CHECKING
+
+	/*
+	 * If asserts are enabled, always build a private state object for
+	 * cross-checks.
+	 */
+	needs_pps = true;
+#endif
+
+	/* Initialize and store private state, if required. */
+	if (needs_pps)
+	{
+		pps = palloc0_object(pgpa_planner_state);
+		pps->explain_state = es;
+		pps->trove = trove;
+#ifdef USE_ASSERT_CHECKING
+		pps->ri_check_hash =
+			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
+#endif
+		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
+	}
+}
+
+/*
+ * Carry out whatever work we want to do after planning is complete.
+ */
+static void
+pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	pgpa_planner_state *pps;
+	pgpa_trove *trove = NULL;
+	ExplainState *es = NULL;
+	pgpa_plan_walker_context walker = {0};	/* placate compiler */
+	bool		do_advice_feedback;
+	bool		do_collect_advice;
+	List	   *pgpa_items = NIL;
+	pgpa_identifier *rt_identifiers = NULL;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
+	if (pps != NULL)
+	{
+		trove = pps->trove;
+		es = pps->explain_state;
+	}
+
+	/* If at least one collector is enabled, generate advice. */
+	do_collect_advice = (pg_plan_advice_local_collection_limit > 0 ||
+						 pg_plan_advice_shared_collection_limit > 0);
+
+	/* If we applied advice, generate feedback. */
+	do_advice_feedback = (trove != NULL && es != NULL);
+
+	/* If either of the above apply, analyze the resulting PlannedStmt. */
+	if (do_collect_advice || do_advice_feedback)
+	{
+		pgpa_plan_walker(&walker, pstmt);
+		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+	}
+
+	/*
+	 * If advice collection is enabled, put the advice in string form and send
+	 * it to the collector.
+	 */
+	if (do_collect_advice)
+	{
+		char	   *advice_string;
+		StringInfoData buf;
+
+		/* Generate a textual advice string. */
+		initStringInfo(&buf);
+		pgpa_output_advice(&buf, &walker, rt_identifiers);
+		advice_string = buf.data;
+
+		/* If the advice string is empty, don't bother collecting it. */
+		if (advice_string[0] != '\0')
+			pgpa_collect_advice(pstmt->queryId, query_string, advice_string);
+
+		/*
+		 * If we've gone to the trouble of generating an advice string, and if
+		 * we're inside EXPLAIN, save the string so we don't need to
+		 * regenerate it.
+		 */
+		if (es != NULL)
+			pgpa_items = lappend(pgpa_items,
+								 makeDefElem("advice_string",
+											 (Node *) makeString(advice_string),
+											 -1));
+	}
+
+	/*
+	 * If we are planning within EXPLAIN, make arrangements to allow EXPLAIN
+	 * to tell the user what has happened with the provided advice.
+	 *
+	 * NB: If EXPLAIN is used on a prepared is a prepared statement, planning
+	 * will have already happened happened without recording these details. We
+	 * could consider adding a GUC to cater to that scenario; or we could do
+	 * this work all the time, but that seems like too much overhead.
+	 */
+	if (do_advice_feedback)
+	{
+		List	   *feedback = NIL;
+
+		/*
+		 * Inject a Node-tree representation of all the trove-entry flags into
+		 * the PlannedStmt.
+		 */
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_SCAN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_JOIN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_REL,
+												rt_identifiers, &walker);
+
+		pgpa_items = lappend(pgpa_items, makeDefElem("feedback",
+													 (Node *) feedback,
+													 -1));
+	}
+
+	/* Push whatever data we're saving into the PlannedStmt. */
+	if (pgpa_items != NIL)
+		pstmt->extension_state =
+			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);
+}
+
+/*
+ * Enforce overall restrictions on a join relation that apply uniformly
+ * regardless of the choice of inner and outer rel.
+ */
+static void
+pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
+								  pgpa_join_state *pjs)
+{
+	int			i = -1;
+	int			flags;
+	bool		gather_conflict = false;
+	uint64		gather_mask = 0;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	bool		partitionwise_conflict = false;
+	int			partitionwise_outcome = 0;
+	Bitmapset  *partitionwise_partial_match = NULL;
+	Bitmapset  *partitionwise_full_match = NULL;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type itm;
+		bool		full_match = false;
+		uint64		my_gather_mask = 0;
+		int			my_partitionwise_outcome = 0;	/* >0 yes, <0 no */
+
+		/*
+		 * For GATHER and GATHER_MERGE, if the specified relations exactly
+		 * match this joinrel, do whatever the advice says; otherwise, don't
+		 * allow Gather or Gather Merge at this level. For NO_GATHER, there
+		 * must be a single target relation which must be included in this
+		 * joinrel, so just don't allow Gather or Gather Merge here, full
+		 * stop.
+		 */
+		if (entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			full_match = true;
+		}
+		else
+		{
+			int			total_count;
+
+			total_count = pjs->outer_count + pjs->inner_count;
+			itm = pgpa_identifiers_match_target(total_count, pjs->rids,
+												entry->target);
+			Assert(itm != PGPA_ITM_DISJOINT);
+
+			if (itm == PGPA_ITM_EQUAL)
+			{
+				full_match = true;
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+					my_partitionwise_outcome = 1;
+				else if (entry->tag == PGPA_TAG_GATHER)
+					my_gather_mask = PGS_GATHER;
+				else if (entry->tag == PGPA_TAG_GATHER_MERGE)
+					my_gather_mask = PGS_GATHER_MERGE;
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+			else
+			{
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else if (entry->tag == PGPA_TAG_GATHER ||
+						 entry->tag == PGPA_TAG_GATHER_MERGE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (full_match)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+
+		/*
+		 * Likewise, if we set my_partitionwise_outcome up above, then we (1)
+		 * make a note if the advice conflicted, (2) remember what the desired
+		 * outcome was, and (3) remember whether this was a full or partial
+		 * match.
+		 */
+		if (my_partitionwise_outcome != 0)
+		{
+			if (partitionwise_outcome != 0 &&
+				partitionwise_outcome != my_partitionwise_outcome)
+				partitionwise_conflict = true;
+			partitionwise_outcome = my_partitionwise_outcome;
+			if (full_match)
+				partitionwise_full_match =
+					bms_add_member(partitionwise_full_match, i);
+			else
+				partitionwise_partial_match =
+					bms_add_member(partitionwise_partial_match, i);
+		}
+	}
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched, and if
+	 * the set of targets exactly matched this relation, fully matched. If
+	 * there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
+
+	/* Likewise for partitionwise advice. */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (partitionwise_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		*pgs_mask_p &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		*pgs_mask_p |= gather_mask;
+	}
+
+	/*
+	 * If there is a non-conflicting partitionwise specification, enforce.
+	 *
+	 * To force a partitionwise join, we disable all the ordinary means of
+	 * performing a join, and instead only Append and MergeAppend paths here.
+	 * To prevent one, we just disable Append and MergeAppend.  Note that we
+	 * must not unset PGS_CONSIDER_PARTITIONWISE even when we don't want a
+	 * partitionwise join here, because we might want one at a higher level
+	 * that is constructing using paths from this level.
+	 */
+	if (partitionwise_outcome != 0 && !partitionwise_conflict)
+	{
+		if (partitionwise_outcome > 0)
+			*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) |
+				PGS_APPEND | PGS_MERGE_APPEND | PGS_CONSIDER_PARTITIONWISE;
+		else
+			*pgs_mask_p &= ~(PGS_APPEND | PGS_MERGE_APPEND);
+	}
+}
+
+/*
+ * Enforce restrictions on the join order or join method.
+ *
+ * Note that, although it is possible to view PARTITIONWISE advice as
+ * controlling the join method, we can't enforce it here, because the code
+ * path where this executes only deals with join paths that are built directly
+ * from a single outer path and a single inner path.
+ */
+static void
+pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
+									char *plan_name,
+									pgpa_join_state *pjs)
+{
+	int			i = -1;
+	Bitmapset  *jo_permit_indexes = NULL;
+	Bitmapset  *jo_deny_indexes = NULL;
+	Bitmapset  *jm_indexes = NULL;
+	bool		jm_conflict = false;
+	uint32		join_mask = 0;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->join_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->join_entries[i];
+		uint32		my_join_mask;
+
+		/* Handle join order advice. */
+		if (entry->tag == PGPA_TAG_JOIN_ORDER)
+		{
+			if (pgpa_join_order_permits_join(pjs->outer_count,
+											 pjs->inner_count,
+											 pjs->rids,
+											 entry))
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			else
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			continue;
+		}
+
+		/* Handle join strategy advice. */
+		my_join_mask = pgpa_join_strategy_mask_from_advice_tag(entry->tag);
+		if (my_join_mask != 0)
+		{
+			bool		permit;
+			bool		restrict_method;
+
+			if (entry->tag == PGPA_TAG_FOREIGN_JOIN)
+				permit = pgpa_opaque_join_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			else
+				permit = pgpa_join_method_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			if (!permit)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				jm_indexes = bms_add_member(jo_permit_indexes, i);
+				if (join_mask != 0 && join_mask != my_join_mask)
+					jm_conflict = true;
+				join_mask = my_join_mask;
+			}
+			continue;
+		}
+
+		/* Handle semijoin uniqueness advice. */
+		if (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE ||
+			entry->tag == PGPA_TAG_SEMIJOIN_NON_UNIQUE)
+		{
+			bool		advice_unique;
+			bool		jt_unique;
+			bool		jt_non_unique;
+			bool		restrict_method;
+
+			/* Advice wants to unique-ify and use a regular join? */
+			advice_unique = (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE);
+
+			/* Planner is trying to unique-ify and use a regular join? */
+			jt_unique = (jointype == JOIN_UNIQUE_INNER ||
+						 jointype == JOIN_UNIQUE_OUTER);
+
+			/* Planner is trying a semi-join, without unique-ifying? */
+			jt_non_unique = (jointype == JOIN_SEMI ||
+							 jointype == JOIN_RIGHT_SEMI);
+
+			/*
+			 * These advice tags behave very much like join method advice, in
+			 * that they want the inner side of the semijoin to match the
+			 * relations listed in the advice. Hence, we test whether join
+			 * method advice would enforce a join order restriction here, and
+			 * disallow the join if not.
+			 *
+			 * XXX. Think harder about right semijoins.
+			 */
+			if (!pgpa_join_method_permits_join(pjs->outer_count,
+											   pjs->inner_count,
+											   pjs->rids,
+											   entry,
+											   &restrict_method))
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				if (!jt_unique && !jt_non_unique)
+				{
+					/*
+					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
+					 * or SJ_NON_UNIQUE can be applied.
+					 */
+					entry->flags |= PGPA_TE_INAPPLICABLE;
+				}
+				else if (advice_unique != jt_unique)
+					jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			}
+			continue;
+		}
+	}
+
+	/*
+	 * If the advice indicates both that this join order is permissible and
+	 * also that it isn't, then mark advice related to the join order as
+	 * conflicting.
+	 */
+	if (jo_permit_indexes != NULL && jo_deny_indexes != NULL)
+	{
+		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * If more than one join method specification is relevant here and they
+	 * differ, mark them all as conflicting.
+	 */
+	if (jm_conflict)
+		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
+							 PGPA_TE_CONFLICTING);
+
+	/*
+	 * If we were advised to deny this join order, then do so. However, if we
+	 * were also advised to permit it, then do nothing, since the advice
+	 * conflicts.
+	 */
+	if (jo_deny_indexes != NULL && jo_permit_indexes == NULL)
+		*pgs_mask_p = 0;
+
+	/*
+	 * If we were advised to restrict the join method, then do so. However, if
+	 * we got conflicting join method advice or were also advised to reject
+	 * this join order completely, then instead do nothing.
+	 */
+	if (join_mask != 0 && !jm_conflict && jo_deny_indexes == NULL)
+		*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) | join_mask;
+}
+
+/*
+ * Translate an advice tag into a path generation strategy mask.
+ *
+ * This function can be called with tag types that don't represent join
+ * strategies. In such cases, we just return 0, which can't be confused with
+ * a valid mask.
+ */
+static uint64
+pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag)
+{
+	switch (tag)
+	{
+		case PGPA_TAG_FOREIGN_JOIN:
+			return PGS_FOREIGNJOIN;
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return PGS_MERGEJOIN_PLAIN;
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return PGS_MERGEJOIN_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return PGS_NESTLOOP_PLAIN;
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return PGS_NESTLOOP_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return PGS_NESTLOOP_MEMOIZE;
+		case PGPA_TAG_HASH_JOIN:
+			return PGS_HASHJOIN;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Does a certain item of join order advice permit a certain join?
+ */
+static bool
+pgpa_join_order_permits_join(int outer_count, int inner_count,
+							 pgpa_identifier *rids,
+							 pgpa_trove_entry *entry)
+{
+	bool		loop = true;
+	bool		sublist = false;
+	int			length;
+	int			outer_length;
+	pgpa_advice_target *target = entry->target;
+	pgpa_advice_target *prefix_target;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * Find the innermost sublist that contains all keys; if no sublist does,
+	 * then continue processing with the toplevel list.
+	 *
+	 * For example, if the advice says JOIN_ORDER(t1 t2 (t3 t4 t5)), then we
+	 * should evaluate joins that only involve t3, t4, and/or t5 against the
+	 * (t3 t4 t5) sublist, and others against the full list.
+	 *
+	 * Note that (1) outermost sublist is always ordered and (2) whenever we
+	 * zoom into an unordered sublist, we instantly accept the proposed join.
+	 * If the advice says JOIN_ORDER(t1 t2 {t3 t4 t5}), any approach to
+	 * joining t3, t4, and/or t5 is acceptable.
+	 */
+	while (loop)
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+		loop = false;
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_itm_type itm;
+
+			if (child_target->ttype == PGPA_TARGET_IDENTIFIER)
+				continue;
+
+			itm = pgpa_identifiers_match_target(outer_count + inner_count,
+												rids, child_target);
+			if (itm == PGPA_ITM_EQUAL || itm == PGPA_ITM_KEYS_ARE_SUBSET)
+			{
+				if (child_target->ttype == PGPA_TARGET_ORDERED_LIST)
+				{
+					target = child_target;
+					sublist = true;
+					loop = true;
+					break;
+				}
+				else
+				{
+					Assert(child_target->ttype == PGPA_TARGET_UNORDERED_LIST);
+					return true;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Try to find a prefix of the selected join order list that is exactly
+	 * equal to the outer side of the proposed join.
+	 */
+	length = list_length(target->children);
+	prefix_target = palloc0_object(pgpa_advice_target);
+	prefix_target->ttype = PGPA_TARGET_ORDERED_LIST;
+	for (outer_length = 1; outer_length <= length; ++outer_length)
+	{
+		pgpa_itm_type itm;
+
+		/* Avoid leaking memory in every loop iteration. */
+		if (prefix_target->children != NULL)
+			list_free(prefix_target->children);
+		prefix_target->children = list_copy_head(target->children,
+												 outer_length);
+
+		/* Search, hoping to find an exact match. */
+		itm = pgpa_identifiers_match_target(outer_count, rids, prefix_target);
+		if (itm == PGPA_ITM_EQUAL)
+			break;
+
+		/*
+		 * If the prefix of the join order list that we're considering
+		 * includes some but not all of the outer rels, we can make the prefix
+		 * longer to find an exact match. But the advice hasn't mentioned
+		 * everything that's part of our outer rel yet, but has mentioned
+		 * things that are not, then this join doesn't match the join order
+		 * list.
+		 */
+		if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+			return false;
+	}
+
+	/*
+	 * If the previous looped stopped before the prefix_target included the
+	 * entire join order list, then the next member of the join order list
+	 * must exactly match the inner side of the join.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), if the outer side of the
+	 * current join includes only t1, then the inner side must be exactly t2;
+	 * if the outer side includes both t1 and t2, then the inner side must
+	 * include exactly t3, t4, and t5.
+	 */
+	if (outer_length < length)
+	{
+		pgpa_advice_target *inner_target;
+		pgpa_itm_type itm;
+
+		inner_target = list_nth(target->children, outer_length);
+
+		itm = pgpa_identifiers_match_target(inner_count, rids + outer_count,
+											inner_target);
+
+		/*
+		 * Before returning, consider whether we need to mark this entry as
+		 * fully matched. If we found every item but one on the lefthand side
+		 * of the join and the last item on the righthand side of the join,
+		 * then the answer is yes.
+		 */
+		if (outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
+			entry->flags |= PGPA_TE_MATCH_FULL;
+
+		return (itm == PGPA_ITM_EQUAL);
+	}
+
+	/*
+	 * If we get here, then the outer side of the join includes the entirety
+	 * of the join order list. In this case, we behave differently depending
+	 * on whether we're looking at the top-level join order list or sublist.
+	 * At the top-level, we treat the specified list as mandating that the
+	 * actual join order has the given list as a prefix, but a sublist
+	 * requires an exact match.
+	 *
+	 * Exmaple: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), we must start by joining
+	 * all five of those relations and in that sequence, but once that is
+	 * done, it's OK to join any other rels that are part of the join problem.
+	 * This allows a user to specify the driving table and perhaps the first
+	 * few things to which it should be joined while leaving the rest of the
+	 * join order up the optimizer. But it seems like it would be surprising,
+	 * given that specification, if the user could add t6 to the (t3 t4 t5)
+	 * sub-join, so we don't allow that. If we did want to allow it, the logic
+	 * earlier in this function would require substantial adjustment: we could
+	 * allow the t3-t4-t5-t6 join to be built here, but the next step of
+	 * joining t1-t2 to the result would still be rejected.
+	 */
+	return !sublist;
+}
+
+/*
+ * Does a certain item of join method advice permit a certain join?
+ *
+ * Advice such as HASH_JOIN((x y)) means that there should be a hash join with
+ * exactly x and y on the inner side. Obviously, this means that if we are
+ * considering a join with exactly x and y on the inner side, we should enforce
+ * the use of a hash join. However, it also means that we must reject some
+ * incompatible join orders entirely.  For example, a join with exactly x
+ * and y on the outer side shouldn't be allowed, because such paths might win
+ * over the advice-driven path on cost.
+ *
+ * To accommodate these requirements, this function returns true if the join
+ * should be allowed and false if it should not. Furthermore, *restrict_method
+ * is set to true if the join method should be enforced and false if not.
+ */
+static bool
+pgpa_join_method_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	/*
+	 * If our inner rel mentions exactly the same relations as the advice
+	 * target, allow the join and enforce the join method restriction.
+	 *
+	 * If our inner rel mentions a superset of the target relations, allow the
+	 * join. The join we care about has already taken place, and this advice
+	 * imposes no further restrictions.
+	 */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If our outer rel mentions a supserset of the relations in the advice
+	 * target, no restrictions apply. The join we care has already taken
+	 * place, and this advice imposes no further restrictions.
+	 *
+	 * On the other hand, if our outer rel mentions exactly the relations
+	 * mentioned in the advice target, the planner is trying to reverse the
+	 * sides of the join as compared with our desired outcome. Reject that.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+	else if (outer_itm == PGPA_ITM_EQUAL)
+		return false;
+
+	/*
+	 * If the advice target mentions only a single relation, the test below
+	 * cannot ever pass, so save some work by exiting now.
+	 */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+		return false;
+
+	/*
+	 * If everything in the joinrel is appears in the advice target, we're
+	 * below the level of the join we want to control.
+	 *
+	 * For example, HASH_JOIN((x y)) doesn't restrict how x and y can be
+	 * joined.
+	 *
+	 * This lookup shouldn't return PGPA_ITM_DISJOINT, because any such advice
+	 * should not have been returned from the trove in the first place.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've already permitted all allowable cases, so reject this.
+	 *
+	 * If we reach this point, then the advice overlaps with this join but
+	 * isn't entirely contained within either side, and there's also at least
+	 * one relation present in the join that isn't mentioned by the advice.
+	 *
+	 * For instance, in the HASH_JOIN((x y)) example, we would reach here if x
+	 * were on one side of the join, y on the other, and at least one of the
+	 * two sides also included some other relation, say t. In that case,
+	 * accepting this join would allow the (x y t) joinrel to contain
+	 * non-disabled paths that do not put (x y) on the inner side of a hash
+	 * join; we could instead end up with something like (x JOIN t) JOIN y.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning an opaque join permit a certain join?
+ *
+ * By an opaque join, we mean one where the exact mechanism by which the
+ * join is performed is not visible to PostgreSQL. Currently this is the
+ * case only for foreign joins: FOREIGN_JOIN((x y z)) means that x, y, and
+ * z are joined on the remote side, but we know nothing about the join order
+ * or join methods used over there.
+ */
+static bool
+pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	if (join_itm == PGPA_ITM_EQUAL)
+	{
+		/*
+		 * We have an exact match, and should therefore allow the join and
+		 * enforce the use of the relevant opaque join method.
+		 */
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+	{
+		/*
+		 * If join_itm == PGPA_ITM_TARGETS_ARE_SUBSET, then the join we care
+		 * about has already taken place and no further restrictions apply.
+		 *
+		 * If join_itm == PGPA_ITM_KEYS_ARE_SUBSET, we're still building up to
+		 * the join we care about and have not introduced any extraneous
+		 * relations not named in the advice. Note that ForeignScan paths for
+		 * joins are built up from ForeignScan paths from underlying joins and
+		 * scans, so we must not disable this join when considering a subset
+		 * of the relations we ultimately want.
+		 */
+		return true;
+	}
+
+	/*
+	 * The advice overlaps the join, but at least one relation is present in
+	 * the join that isn't mentioned by the advice. We want to disable such
+	 * paths so that we actually push down the join as intended.
+	 */
+	return false;
+}
+
+/*
+ * Apply scan advice to a RelOptInfo.
+ *
+ * XXX. For bitmap heap scans, we're just ignoring the index information from
+ * the advice. That's not cool.
+ */
+static void
+pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+							   pgpa_trove_entry *scan_entries,
+							   Bitmapset *scan_indexes,
+							   pgpa_trove_entry *rel_entries,
+							   Bitmapset *rel_indexes)
+{
+	bool		gather_conflict = false;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	int			i = -1;
+	pgpa_trove_entry *scan_entry = NULL;
+	int			flags;
+	bool		scan_type_conflict = false;
+	Bitmapset  *scan_type_indexes = NULL;
+	Bitmapset  *scan_type_rel_indexes = NULL;
+	uint64		gather_mask = 0;
+	uint64		scan_type = 0;
+
+	/* 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;
+
+		/* Translate our advice tags to a scan strategy advice value. */
+		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+			my_scan_type = PGS_BITMAPSCAN;
+		else if (my_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN)
+			my_scan_type = PGS_INDEXONLYSCAN | PGS_CONSIDER_INDEXONLY;
+		else if (my_entry->tag == PGPA_TAG_INDEX_SCAN)
+			my_scan_type = PGS_INDEXSCAN;
+		else if (my_entry->tag == PGPA_TAG_SEQ_SCAN)
+			my_scan_type = PGS_SEQSCAN;
+		else if (my_entry->tag == PGPA_TAG_TID_SCAN)
+			my_scan_type = PGS_TIDSCAN;
+
+		/*
+		 * If this is understandable scan advice, hang on to the entry, the
+		 * inferred scan type type, and the index at which we found it.
+		 *
+		 * Also make a note if we see conflicting scan type advice. Note that
+		 * we regard two index specifications as conflicting unless they match
+		 * exactly. In theory, perhaps we could regard INDEX_SCAN(a c) and
+		 * 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 (scan_type != 0 && scan_type != my_scan_type)
+				scan_type_conflict = true;
+			if (!scan_type_conflict && scan_entry != NULL &&
+				my_entry->target->itarget != NULL &&
+				scan_entry->target->itarget != NULL &&
+				!pgpa_index_targets_equal(scan_entry->target->itarget,
+										  my_entry->target->itarget))
+				scan_type_conflict = true;
+			scan_entry = my_entry;
+			scan_type = my_scan_type;
+			scan_type_indexes = bms_add_member(scan_type_indexes, i);
+		}
+	}
+
+	/* Scrutinize available gather-related and partitionwise advice. */
+	i = -1;
+	while ((i = bms_next_member(rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *my_entry = &rel_entries[i];
+		uint64		my_gather_mask = 0;
+		bool		just_one_rel;
+
+		just_one_rel = my_entry->target->ttype == PGPA_TARGET_IDENTIFIER
+			|| list_length(my_entry->target->children) == 1;
+
+		/*
+		 * PARTITIONWISE behaves like a scan type, except that if there's more
+		 * than one relation targeted, it has no effect at this level.
+		 */
+		if (my_entry->tag == PGPA_TAG_PARTITIONWISE)
+		{
+			if (just_one_rel)
+			{
+				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
+
+				if (scan_type != 0 && scan_type != my_scan_type)
+					scan_type_conflict = true;
+				scan_entry = my_entry;
+				scan_type = my_scan_type;
+				scan_type_rel_indexes =
+					bms_add_member(scan_type_rel_indexes, i);
+			}
+			continue;
+		}
+
+		/*
+		 * GATHER and GATHER_MERGE applied to a single rel mean that we should
+		 * use the correspondings strategy here, while applying either to more
+		 * than one rel means we should not use those strategies here, but
+		 * rather at the level of the joinrel that corresponds to what was
+		 * specified. NO_GATHER can only be applied to single rels.
+		 *
+		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
+		 * equivalent to allowing the non-use of either form of Gather here.
+		 */
+		if (my_entry->tag == PGPA_TAG_GATHER ||
+			my_entry->tag == PGPA_TAG_GATHER_MERGE)
+		{
+			if (!just_one_rel)
+				my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			else if (my_entry->tag == PGPA_TAG_GATHER)
+				my_gather_mask = PGS_GATHER;
+			else
+				my_gather_mask = PGS_GATHER_MERGE;
+		}
+		else if (my_entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			Assert(just_one_rel);
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (just_one_rel)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+	}
+
+	/* Enforce choice of index. */
+	if (scan_entry != NULL && !scan_type_conflict &&
+		(scan_entry->tag == PGPA_TAG_INDEX_SCAN ||
+		 scan_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN))
+	{
+		pgpa_index_target *itarget = scan_entry->target->itarget;
+		IndexOptInfo *matched_index = NULL;
+
+		Assert(itarget->itype == PGPA_INDEX_NAME);
+
+		foreach_node(IndexOptInfo, index, rel->indexlist)
+		{
+			char	   *relname = get_rel_name(index->indexoid);
+			Oid			nspoid = get_rel_namespace(index->indexoid);
+			char	   *relnamespace = get_namespace_name(nspoid);
+
+			if (strcmp(itarget->indname, relname) == 0 &&
+				(itarget->indnamespace == NULL ||
+				 strcmp(itarget->indnamespace, relnamespace) == 0))
+			{
+				matched_index = index;
+				break;
+			}
+		}
+
+		if (matched_index == NULL)
+		{
+			/* Don't force the scan type if the index doesn't exist. */
+			scan_type = 0;
+
+			/* Mark advice as inapplicable. */
+			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
+								 PGPA_TE_INAPPLICABLE);
+		}
+		else
+		{
+			/* Retain this index and discard the rest. */
+			rel->indexlist = list_make1(matched_index);
+		}
+	}
+
+	/*
+	 * Mark all the scan method entries as fully matched; and if they specify
+	 * different things, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	if (scan_type_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
+	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched. Mark
+	 * the ones that included this relation as a target by itself as fully
+	 * matched. If there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
+
+	/* If there is a non-conflicting scan specification, enforce it. */
+	if (scan_type != 0 && !scan_type_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
+			  PGS_CONSIDER_INDEXONLY);
+		rel->pgs_mask |= scan_type;
+	}
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		rel->pgs_mask |= gather_mask;
+	}
+}
+
+/*
+ * Add feedback entries to for one trove slice to the provided list and
+ * return the resulting list.
+ *
+ * Feedback entries are generated from the trove entry's flags. It's assumed
+ * that the caller has already set all relevant flags with the exception of
+ * PGPA_TE_FAILED. We set that flag here if appropriate.
+ */
+static List *
+pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+							 pgpa_trove_lookup_type type,
+							 pgpa_identifier *rt_identifiers,
+							 pgpa_plan_walker_context *walker)
+{
+	pgpa_trove_entry *entries;
+	int			nentries;
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	pgpa_trove_lookup_all(trove, type, &entries, &nentries);
+	for (int i = 0; i < nentries; ++i)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+		DefElem    *item;
+
+		/*
+		 * If this entry was fully matched, check whether generating advice
+		 * from this plan would produce such an entry. If not, label the entry
+		 * as failed.
+		 */
+		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+			!pgpa_walker_would_advise(walker, rt_identifiers,
+									  entry->tag, entry->target))
+			entry->flags |= PGPA_TE_FAILED;
+
+		item = makeDefElem(pgpa_cstring_trove_entry(entry),
+						   (Node *) makeInteger(entry->flags), -1);
+		list = lappend(list, item);
+	}
+
+	return list;
+}
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * Fast hash function for a key consisting of an RTI and plan name.
+ */
+static uint32
+pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	hs.accum = key.rti;
+	fasthash_combine(&hs);
+
+	/* plan_name can be NULL */
+	if (key.plan_name == NULL)
+		sp_len = 0;
+	else
+		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+
+	/* hashfn_unstable.h recommends using string length as tweak */
+	return fasthash_final32(&hs, sp_len);
+}
+
+#endif
+
+/*
+ * Save the range table identifier for one relation for future cross-checking.
+ */
+static void
+pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
+					 RelOptInfo *rel)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_checker_key key;
+	pgpa_ri_checker *check;
+	pgpa_identifier rid;
+	const char *rid_string;
+	bool		found;
+
+	key.rti = bms_singleton_member(rel->relids);
+	key.plan_name = root->plan_name;
+	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
+	rid_string = pgpa_identifier_string(&rid);
+	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
+	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
+	check->rid_string = rid_string;
+#endif
+}
+
+/*
+ * 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_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	pgpa_ri_check_iterator it;
+	pgpa_ri_checker *check;
+
+	/* 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. */
+	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
+	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	{
+		int			rtoffset = 0;
+		const char *rid_string;
+		Index		flat_rti;
+
+		/*
+		 * 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.
+		 */
+		if (check->key.plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				/*
+				 * If rtinfo->dummy is set, then the subquery's range table
+				 * will only have been partially copied to the final range
+				 * table. Specifically, only RTE_RELATION entries and
+				 * 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
+				 * in the final rtable.
+				 *
+				 * With more complex logic, we could work around that problem
+				 * by remembering the whole contents of the subquery's rtable
+				 * during planning, determining which of those would have been
+				 * copied to the final rtable, and matching them up. But it
+				 * doesn't seem like a worthwhile endeavor for right now,
+				 * because RTIs from such subqueries won't appear in the plan
+				 * tree itself, just in the range table. Hence, we can neither
+				 * generate nor accept advice for them.
+				 */
+				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+					&& !rtinfo->dummy)
+				{
+					rtoffset = rtinfo->rtoffset;
+					Assert(rtoffset > 0);
+					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;
+		}
+
+		/*
+		 * check->key.rti is the RTI that we saw prior to range-table
+		 * flattening, so we must add the appropriate RT offset to get the
+		 * final RTI.
+		 */
+		flat_rti = check->key.rti + rtoffset;
+		Assert(flat_rti <= list_length(pstmt->rtable));
+
+		/* Assert that the string we compute now matches the previous one. */
+		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
+		Assert(strcmp(rid_string, check->rid_string) == 0);
+	}
+#endif
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
new file mode 100644
index 00000000000..7d40b910b00
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -0,0 +1,17 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.h
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_PLANNER_H
+#define PGPA_PLANNER_H
+
+extern void pgpa_planner_install_hooks(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
new file mode 100644
index 00000000000..b351d3dbf92
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -0,0 +1,258 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.c
+ *	  analysis of scans in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+
+static pgpa_scan *pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								 pgpa_scan_strategy strategy,
+								 Bitmapset *relids,
+								 bool beneath_any_gather);
+
+
+static RTEKind unique_nonjoin_rtekind(Bitmapset *relids, List *rtable);
+
+/*
+ * Build a pgpa_scan object for a Plan node and update the plan walker
+ * context as appopriate.  If this is an Append or MergeAppend scan, also
+ * build pgpa_scan for any scans that were consolidated into this one by
+ * Append/MergeAppend pull-up.
+ *
+ * If there is at least one ElidedNode for this plan node, pass the uppermost
+ * one as elided_node, else pass NULL.
+ *
+ * Set the 'beneath_any_gather' node if we are underneath a Gather or
+ * Gather Merge node.
+ *
+ * Set the 'within_join_problem' flag if we're inside of a join problem and
+ * not otherwise.
+ */
+pgpa_scan *
+pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+				ElidedNode *elided_node,
+				bool beneath_any_gather, bool within_join_problem)
+{
+	pgpa_scan_strategy strategy = PGPA_SCAN_ORDINARY;
+	Bitmapset  *relids = NULL;
+	int			rti = -1;
+	List	   *child_append_relid_sets = NIL;
+
+	if (elided_node != NULL)
+	{
+		NodeTag		elided_type = elided_node->elided_type;
+
+		/*
+		 * If setrefs processing elided an Append or MergeAppend node that had
+		 * only one surviving child, then this is a partitionwise "scan" --
+		 * which may really be a partitionwise join, but there's no need to
+		 * distinguish.
+		 *
+		 * If it's a trivial SubqueryScan that was elided, then this is an
+		 * "ordinary" scan i.e. one for which we need to generate advice
+		 * because the planner has not made any meaningful choice.
+		 */
+		relids = elided_node->relids;
+		if (elided_type == T_Append || elided_type == T_MergeAppend)
+			strategy = PGPA_SCAN_PARTITIONWISE;
+		else
+			strategy = PGPA_SCAN_ORDINARY;
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+	{
+		relids = bms_make_singleton(rti);
+
+		switch (nodeTag(plan))
+		{
+			case T_SeqScan:
+				strategy = PGPA_SCAN_SEQ;
+				break;
+			case T_BitmapHeapScan:
+				strategy = PGPA_SCAN_BITMAP_HEAP;
+				break;
+			case T_IndexScan:
+				strategy = PGPA_SCAN_INDEX;
+				break;
+			case T_IndexOnlyScan:
+				strategy = PGPA_SCAN_INDEX_ONLY;
+				break;
+			case T_TidScan:
+			case T_TidRangeScan:
+				strategy = PGPA_SCAN_TID;
+				break;
+			default:
+
+				/*
+				 * This case includes a ForeignScan targeting a single
+				 * relation; no other strategy is possible in that case, but
+				 * see below, where things are different in multi-relation
+				 * cases.
+				 */
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+	}
+	else if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		switch (nodeTag(plan))
+		{
+			case T_ForeignScan:
+
+				/*
+				 * If multiple relations are being targeted by a single
+				 * foreign scan, then the foreign join has been pushed to the
+				 * remote side, and we want that to be reflected in the
+				 * generated advice.
+				 */
+				strategy = PGPA_SCAN_FOREIGN;
+				break;
+			case T_Append:
+
+				/*
+				 * Append nodes can represent partitionwise scans of a a
+				 * relation, but when they implement a set operation, they are
+				 * just ordinary scans.
+				 */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((Append *) plan)->child_append_relid_sets;
+				break;
+			case T_MergeAppend:
+				/* Some logic here as for Append, above. */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+				child_append_relid_sets =
+					((MergeAppend *) plan)->child_append_relid_sets;
+				break;
+			default:
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+
+	/*
+	 * If this is an Append or MergeAppend node into which subordinate Append
+	 * or MergeAppend paths were merged, each of those merged paths is
+	 * effectively another scan for which we need to account.
+	 */
+	foreach_node(Bitmapset, child_relids, child_append_relid_sets)
+	{
+		Bitmapset  *child_nonjoin_relids;
+
+		child_nonjoin_relids =
+			pgpa_filter_out_join_relids(child_relids,
+										walker->pstmt->rtable);
+		(void) pgpa_make_scan(walker, plan, strategy,
+							  child_nonjoin_relids,
+							  beneath_any_gather);
+	}
+
+	/*
+	 * If this plan node has no associated RTIs, it's not a scan. When the
+	 * 'within_join_problem' flag is set, that's unexpected, so throw an
+	 * error, else return quietly.
+	 */
+	if (relids == NULL)
+	{
+		if (within_join_problem)
+			elog(ERROR, "plan node has no RTIs: %d", (int) nodeTag(plan));
+		return NULL;
+	}
+
+	return pgpa_make_scan(walker, plan, strategy, relids, beneath_any_gather);
+}
+
+/*
+ * Create a single pgpa_scan object and update the pgpa_plan_walker_context.
+ */
+static pgpa_scan *
+pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+			   pgpa_scan_strategy strategy, Bitmapset *relids,
+			   bool beneath_any_gather)
+{
+	pgpa_scan  *scan;
+
+	/* Create the scan object. */
+	scan = palloc(sizeof(pgpa_scan));
+	scan->plan = plan;
+	scan->strategy = strategy;
+	scan->relids = relids;
+	scan->beneath_any_gather = beneath_any_gather;
+
+	/* Add it to the appropriate list. */
+	walker->scans[scan->strategy] = lappend(walker->scans[scan->strategy],
+											scan);
+
+	/*
+	 * We intend to emit NO_GATHER() advice for each scan that doesn't appear
+	 * beneath a Gather or Gather Merge node, but we need not do this for
+	 * partitionwise scans, because emitting NO_GATHER() for the child scans
+	 * suffices.
+	 */
+	if (!scan->beneath_any_gather && scan->strategy != PGPA_SCAN_PARTITIONWISE)
+		walker->no_gather_scans = bms_add_members(walker->no_gather_scans,
+												  scan->relids);
+
+	return scan;
+}
+
+/*
+ * Determine the unique rtekind of a set of relids.
+ */
+static RTEKind
+unique_nonjoin_rtekind(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	bool		first = true;
+	RTEKind		rtekind;
+
+	Assert(relids != NULL);
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		if (first)
+		{
+			rtekind = rte->rtekind;
+			first = false;
+		}
+		else if (rtekind != rte->rtekind)
+			elog(ERROR, "rtekind mismatch: %d vs. %d",
+				 rtekind, rte->rtekind);
+	}
+
+	if (first)
+		elog(ERROR, "no non-RTE_JOIN RTEs found");
+
+	return rtekind;
+}
diff --git a/contrib/pg_plan_advice/pgpa_scan.h b/contrib/pg_plan_advice/pgpa_scan.h
new file mode 100644
index 00000000000..90a08b41c5b
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.h
@@ -0,0 +1,86 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.h
+ *	  analysis of scans in Plan trees
+ *
+ * For purposes of this module, a "scan" includes (1) single plan nodes that
+ * scan multiple RTIs, such as a degenerate Result node that replaces what
+ * would otherwise have been a join, and (2) Append and MergeAppend nodes
+ * implementing a partitionwise scan or a partitionwise join. Said
+ * differently, scans are the leaves of the join tree for a single join
+ * problem.
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_SCAN_H
+#define PGPA_SCAN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+
+/*
+ * Scan strategies.
+ *
+ * PGPA_SCAN_ORDINARY is any scan strategy that isn't interesting to us
+ * because there is no meaningful planner decision involved. For example,
+ * the only way to scan a subquery is a SubqueryScan, and the only way to
+ * scan a VALUES construct is a ValuesScan. We need not care exactly which
+ * type of planner node was used in such cases, because the same thing will
+ * happen when replanning.
+ *
+ * PGPA_SCAN_ORDINARY also includes Result nodes that correspond to scans
+ * or even joins that are proved empty. We don't know whether or not the scan
+ * or join will still be provably empty at replanning time, but if it is,
+ * then no scan-type advice is needed, and if it's not, we can't recommend
+ * a scan type based on the current plan.
+ *
+ * PGPA_SCAN_PARTITIONWISE also lumps together scans and joins: this can
+ * be either a partitionwise scan of a partitioned table or a partitionwise
+ * join between several partitioned tables. Note that all decisions about
+ * whether or not to use partitionwise join are meaningful: no matter what
+ * we decided this time, we could do more or fewer things partitionwise the
+ * next time.
+ *
+ * PGPA_SCAN_FOREIGN is only used when there's more than one relation involved;
+ * a single-table foreign scan is classified as ordinary, since there is no
+ * decision to make in that case.
+ *
+ * Other scan strategies map one-to-one to plan nodes.
+ */
+typedef enum
+{
+	PGPA_SCAN_ORDINARY = 0,
+	PGPA_SCAN_SEQ,
+	PGPA_SCAN_BITMAP_HEAP,
+	PGPA_SCAN_FOREIGN,
+	PGPA_SCAN_INDEX,
+	PGPA_SCAN_INDEX_ONLY,
+	PGPA_SCAN_PARTITIONWISE,
+	PGPA_SCAN_TID
+	/* update NUM_PGPA_SCAN_STRATEGY if you add anything here */
+} pgpa_scan_strategy;
+
+#define NUM_PGPA_SCAN_STRATEGY	((int) PGPA_SCAN_TID + 1)
+
+/*
+ * All of the details we need regarding a scan.
+ */
+typedef struct pgpa_scan
+{
+	Plan	   *plan;
+	pgpa_scan_strategy strategy;
+	Bitmapset  *relids;
+	bool		beneath_any_gather;
+} pgpa_scan;
+
+extern pgpa_scan *pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								  ElidedNode *elided_node,
+								  bool beneath_any_gather,
+								  bool within_join_problem);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scanner.l b/contrib/pg_plan_advice/pgpa_scanner.l
new file mode 100644
index 00000000000..be7d7ba13a6
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scanner.l
@@ -0,0 +1,299 @@
+%top{
+/*
+ * Scanner for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_scanner.l
+ */
+#include "postgres.h"
+
+#include "common/string.h"
+#include "nodes/miscnodes.h"
+#include "parser/scansup.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Extra data that we pass around when during scanning.
+ *
+ * 'litbuf' is used to implement the <xd> exclusive state, which handles
+ * double-quoted identifiers.
+ */
+typedef struct pgpa_yy_extra_type
+{
+	StringInfoData	litbuf;
+} pgpa_yy_extra_type;
+
+}
+
+%{
+/* LCOV_EXCL_START */
+
+#define YY_DECL \
+	extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result, \
+						  char **parse_error_msg_p, yyscan_t yyscanner)
+
+/* No reason to constrain amount of data slurped */
+#define YY_READ_BUF_SIZE 16777216
+
+/* Avoid exit() on fatal scanner errors (a bit ugly -- see yy_fatal_error) */
+#undef fprintf
+#define fprintf(file, fmt, msg)  fprintf_to_ereport(fmt, msg)
+
+static void
+fprintf_to_ereport(const char *fmt, const char *msg)
+{
+	ereport(ERROR, (errmsg_internal("%s", msg)));
+}
+%}
+
+%option reentrant
+%option bison-bridge
+%option 8bit
+%option never-interactive
+%option nodefault
+%option noinput
+%option nounput
+%option noyywrap
+%option noyyalloc
+%option noyyrealloc
+%option noyyfree
+%option warn
+%option prefix="pgpa_yy"
+%option extra-type="pgpa_yy_extra_type *"
+
+/*
+ * What follows is a severely stripped-down version of the core scanner. We
+ * only care about recognizing identifiers with or without identifier quoting
+ * (i.e. double-quoting), decimal integers, and a small handful of other
+ * things. Keep these rules in sync with src/backend/parser/scan.l. As in that
+ * file, we use an exclusive state called 'xc' for C-style comments, and an
+ * exclusive state called 'xd' for double-quoted identifiers.
+ */
+%x xc
+%x xd
+
+ident_start		[A-Za-z\200-\377_]
+ident_cont		[A-Za-z\200-\377_0-9\$]
+
+identifier		{ident_start}{ident_cont}*
+
+decdigit		[0-9]
+decinteger		{decdigit}(_?{decdigit})*
+
+space			[ \t\n\r\f\v]
+whitespace		{space}+
+
+dquote			\"
+xdstart			{dquote}
+xdstop			{dquote}
+xddouble		{dquote}{dquote}
+xdinside		[^"]+
+
+xcstart			\/\*
+xcstop			\*+\/
+xcinside		[^*/]+
+
+%%
+
+{whitespace}	{ /* ignore */ }
+
+{identifier}	{
+					char   *str;
+					bool	fail;
+					pgpa_advice_tag_type	tag;
+
+					/*
+					 * Unlike the core scanner, we don't truncate identifiers
+					 * here. There is no obvious reason to do so.
+					 */
+					str = downcase_identifier(yytext, yyleng, false, false);
+					yylval->str = str;
+
+					/*
+					 * If it's not a tag, just return TOK_IDENT; else, return
+					 * a token type based on how further parsing should
+					 * proceed.
+					 */
+					tag = pgpa_parse_advice_tag(str, &fail);
+					if (fail)
+						return TOK_IDENT;
+					else if (tag == PGPA_TAG_JOIN_ORDER)
+						return TOK_TAG_JOIN_ORDER;
+					else if (tag == PGPA_TAG_INDEX_SCAN ||
+							 tag == PGPA_TAG_INDEX_ONLY_SCAN)
+						return TOK_TAG_INDEX;
+					else if (tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+						return TOK_TAG_BITMAP;
+					else if (tag == PGPA_TAG_SEQ_SCAN ||
+							 tag == PGPA_TAG_TID_SCAN ||
+							 tag == PGPA_TAG_NO_GATHER)
+						return TOK_TAG_SIMPLE;
+					else
+						return TOK_TAG_GENERIC;
+				}
+
+{decinteger}	{
+					char   *endptr;
+
+					errno = 0;
+					yylval->integer = strtoint(yytext, &endptr, 10);
+					if (*endptr != '\0' || errno == ERANGE)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "integer out of range");
+					return TOK_INTEGER;
+				}
+
+{xcstart}		{
+					BEGIN(xc);
+				}
+
+{xdstart}		{
+					BEGIN(xd);
+					resetStringInfo(&yyextra->litbuf);
+				}
+
+"||"			{ return TOK_OR; }
+
+"&&"			{ return TOK_AND; }
+
+.				{ return yytext[0]; }
+
+<xc>{xcstop}	{
+					BEGIN(INITIAL);
+				}
+
+<xc>{xcinside}	{
+					/* discard multiple characters without slash or asterisk */
+				}
+
+<xc>.			{
+					/*
+					 * Discard any single character. flex prefers longer
+					 * matches, so this rule will never be picked when we could
+					 * have matched xcstop.
+					 *
+					 * NB: At present, we don't bother to support nested
+					 * C-style comments here, but this logic could be extended
+					 * if that restriction poses a problem.
+					 */
+				}
+
+<xc><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated comment");
+				}
+
+<xd>{xdstop}	{
+					BEGIN(INITIAL);
+					yylval->str = pstrdup(yyextra->litbuf.data);
+					return TOK_IDENT;
+				}
+
+<xd>{xddouble}	{
+					appendStringInfoChar(&yyextra->litbuf, '"');
+				}
+
+<xd>{xdinside}	{
+					appendBinaryStringInfo(&yyextra->litbuf, yytext, yyleng);
+				}
+
+<xd><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated quoted identifier");
+				}
+
+%%
+
+/* LCOV_EXCL_STOP */
+
+/*
+ * Handler for errors while scanning or parsing advice.
+ *
+ * bison passes the error message to us via 'message', and the context is
+ * available via the 'yytext' macro. We assemble those values into a final
+ * error text and then arrange to pass it back to the caller of pgpa_yyparse()
+ * by storing it into *parse_error_msg_p.
+ */
+void
+pgpa_yyerror(List **result, char **parse_error_msg_p, yyscan_t yyscanner,
+			 const char *message)
+{
+	struct yyguts_t *yyg = (struct yyguts_t *) yyscanner;	/* needed for yytext
+															 * macro */
+
+
+	/* report only the first error in a parse operation */
+	if (*parse_error_msg_p)
+		return;
+
+	if (yytext[0])
+		*parse_error_msg_p = psprintf("%s at or near \"%s\"", message, yytext);
+	else
+		*parse_error_msg_p = psprintf("%s at end of input", message);
+}
+
+/*
+ * Initialize the advice scanner.
+ *
+ * This should be called before parsing begins.
+ */
+void
+pgpa_scanner_init(const char *str, yyscan_t *yyscannerp)
+{
+	yyscan_t	yyscanner;
+	pgpa_yy_extra_type	*yyext = palloc0_object(pgpa_yy_extra_type);
+
+	if (yylex_init(yyscannerp) != 0)
+		elog(ERROR, "yylex_init() failed: %m");
+
+	yyscanner = *yyscannerp;
+
+	initStringInfo(&yyext->litbuf);
+	pgpa_yyset_extra(yyext, yyscanner);
+
+	yy_scan_string(str, yyscanner);
+}
+
+
+/*
+ * Shut down the advice scanner.
+ *
+ * This should be called after parsing is complete.
+ */
+void
+pgpa_scanner_finish(yyscan_t yyscanner)
+{
+	yylex_destroy(yyscanner);
+}
+
+/*
+ * Interface functions to make flex use palloc() instead of malloc().
+ * It'd be better to make these static, but flex insists otherwise.
+ */
+
+void *
+yyalloc(yy_size_t size, yyscan_t yyscanner)
+{
+	return palloc(size);
+}
+
+void *
+yyrealloc(void *ptr, yy_size_t size, yyscan_t yyscanner)
+{
+	if (ptr)
+		return repalloc(ptr, size);
+	else
+		return palloc(size);
+}
+
+void
+yyfree(void *ptr, yyscan_t yyscanner)
+{
+	if (ptr)
+		pfree(ptr);
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
new file mode 100644
index 00000000000..a92121feb1d
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -0,0 +1,490 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.c
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * This name comes from the English expression "trove of advice", which
+ * means a collection of wisdom. This slightly unusual term is chosen to
+ * avoid naming confusion; for example, "collection of advice" would
+ * invite confusion with pgpa_collector.c. Note that, while we don't know
+ * whether the provided advice is actually wise, it's not our job to
+ * question the user's choices.
+ *
+ * The goal of this module is to make it easy to locate the specific
+ * bits of advice that pertain to any given part of a query, or to
+ * determine that there are none.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_trove.h"
+
+#include "common/hashfn_unstable.h"
+
+/*
+ * An advice trove is organized into a series of "slices", each of which
+ * contains information about one topic e.g. scan methods. Each slice consists
+ * of an array of trove entries plus a hash table that we can use to determine
+ * which ones are relevant to a particular part of the query.
+ */
+typedef struct pgpa_trove_slice
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	pgpa_trove_entry *entries;
+	struct pgpa_trove_entry_hash *hash;
+} pgpa_trove_slice;
+
+/*
+ * Scan advice is stored into 'scan'; join advice is stored into 'join'; and
+ * advice that can apply to both cases is stored into 'rel'. This lets callers
+ * ask just for what's relevant. These slices correspond to the possible values
+ * of pgpa_trove_lookup_type.
+ */
+struct pgpa_trove
+{
+	pgpa_trove_slice join;
+	pgpa_trove_slice rel;
+	pgpa_trove_slice scan;
+};
+
+/*
+ * We're going to build a hash table to allow clients of this module to find
+ * relevant advice for a given part of the query quickly. However, we're going
+ * to use only three of the five key fields as hash keys. There are two reasons
+ * for this.
+ *
+ * First, it's allowable to set partition_schema to NULL to match a partition
+ * with the correct name in any schema.
+ *
+ * Second, we expect the "occurrence" and "partition_schema" portions of the
+ * relation identifiers to be mostly uninteresting. Most of the time, the
+ * occurrence field will be 1 and the partition_schema values will all be the
+ * same. Even when there is some variation, the absolute number of entries
+ * that have the same values for all three of these key fields should be
+ * quite small.
+ */
+typedef struct
+{
+	const char *alias_name;
+	const char *partition_name;
+	const char *plan_name;
+} pgpa_trove_entry_key;
+
+typedef struct
+{
+	pgpa_trove_entry_key key;
+	int			status;
+	Bitmapset  *indexes;
+} pgpa_trove_entry_element;
+
+static uint32 pgpa_trove_entry_hash_key(pgpa_trove_entry_key key);
+
+static inline bool
+pgpa_trove_entry_compare_key(pgpa_trove_entry_key a, pgpa_trove_entry_key b)
+{
+	if (strcmp(a.alias_name, b.alias_name) != 0)
+		return false;
+
+	if (!strings_equal_or_both_null(a.partition_name, b.partition_name))
+		return false;
+
+	if (!strings_equal_or_both_null(a.plan_name, b.plan_name))
+		return false;
+
+	return true;
+}
+
+#define SH_PREFIX			pgpa_trove_entry
+#define SH_ELEMENT_TYPE		pgpa_trove_entry_element
+#define SH_KEY_TYPE			pgpa_trove_entry_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_trove_entry_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_trove_entry_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static void pgpa_init_trove_slice(pgpa_trove_slice *tslice);
+static void pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+									pgpa_advice_tag_type tag,
+									pgpa_advice_target *target);
+static void pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash,
+								   pgpa_advice_target *target,
+								   int index);
+static Bitmapset *pgpa_trove_slice_lookup(pgpa_trove_slice *tslice,
+										  pgpa_identifier *rid);
+
+/*
+ * Build a trove of advice from a list of advice items.
+ *
+ * Caller can obtain a list of advice items to pass to this function by
+ * calling pgpa_parse().
+ */
+pgpa_trove *
+pgpa_build_trove(List *advice_items)
+{
+	pgpa_trove *trove = palloc_object(pgpa_trove);
+
+	pgpa_init_trove_slice(&trove->join);
+	pgpa_init_trove_slice(&trove->rel);
+	pgpa_init_trove_slice(&trove->scan);
+
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		switch (item->tag)
+		{
+			case PGPA_TAG_JOIN_ORDER:
+				{
+					pgpa_advice_target *target;
+
+					/*
+					 * For most advice types, each element in the top-level
+					 * list is a separate target, but it's most convenient to
+					 * regard the entirety of a JOIN_ORDER specification as a
+					 * single target. Since it wasn't represented that way
+					 * during parsing, build a surrogate object now.
+					 */
+					target = palloc0_object(pgpa_advice_target);
+					target->ttype = PGPA_TARGET_ORDERED_LIST;
+					target->children = item->targets;
+
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_INDEX_ONLY_SCAN:
+			case PGPA_TAG_INDEX_SCAN:
+			case PGPA_TAG_SEQ_SCAN:
+			case PGPA_TAG_TID_SCAN:
+
+				/*
+				 * Scan advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					/*
+					 * For now, all of our scan types target single relations,
+					 * but in the future this might not be true, e.g. a custom
+					 * scan could replace a join.
+					 */
+					Assert(target->ttype == PGPA_TARGET_IDENTIFIER);
+					pgpa_trove_add_to_slice(&trove->scan,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_FOREIGN_JOIN:
+			case PGPA_TAG_HASH_JOIN:
+			case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			case PGPA_TAG_MERGE_JOIN_PLAIN:
+			case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			case PGPA_TAG_NESTED_LOOP_PLAIN:
+			case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			case PGPA_TAG_SEMIJOIN_UNIQUE:
+
+				/*
+				 * Join strategy advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_PARTITIONWISE:
+			case PGPA_TAG_GATHER:
+			case PGPA_TAG_GATHER_MERGE:
+			case PGPA_TAG_NO_GATHER:
+
+				/*
+				 * Advice about a RelOptInfo relevant to both scans and joins.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->rel,
+											item->tag, target);
+				}
+				break;
+		}
+	}
+
+	return trove;
+}
+
+/*
+ * Search a trove of advice for relevant entries.
+ *
+ * All parameters are input parameters except for *result, which is an output
+ * parameter used to return results to the caller.
+ */
+void
+pgpa_trove_lookup(pgpa_trove *trove, pgpa_trove_lookup_type type,
+				  int nrids, pgpa_identifier *rids, pgpa_trove_result *result)
+{
+	pgpa_trove_slice *tslice;
+	Bitmapset  *indexes;
+
+	Assert(nrids > 0);
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	indexes = pgpa_trove_slice_lookup(tslice, &rids[0]);
+	for (int i = 1; i < nrids; ++i)
+	{
+		Bitmapset  *other_indexes;
+
+		/*
+		 * If the caller is asking about two relations that aren't part of the
+		 * same subquery, they've messed up.
+		 */
+		Assert(strings_equal_or_both_null(rids[0].plan_name,
+										  rids[i].plan_name));
+
+		other_indexes = pgpa_trove_slice_lookup(tslice, &rids[i]);
+		indexes = bms_union(indexes, other_indexes);
+	}
+
+	result->entries = tslice->entries;
+	result->indexes = indexes;
+}
+
+/*
+ * Return all entries in a trove slice to the caller.
+ *
+ * The first two arguments are input arguments, and the remainder are output
+ * arguments.
+ */
+void
+pgpa_trove_lookup_all(pgpa_trove *trove, pgpa_trove_lookup_type type,
+					  pgpa_trove_entry **entries, int *nentries)
+{
+	pgpa_trove_slice *tslice;
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	*entries = tslice->entries;
+	*nentries = tslice->nused;
+}
+
+/*
+ * Convert a trove entry to an item of plan advice that would produce it.
+ */
+char *
+pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	appendStringInfo(&buf, "%s", pgpa_cstring_advice_tag(entry->tag));
+
+	/* JOIN_ORDER tags are transformed by pgpa_build_trove; undo that here */
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, '(');
+	else
+		Assert(entry->target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	pgpa_format_advice_target(&buf, entry->target);
+
+	if (entry->target->itarget != NULL)
+	{
+		appendStringInfoChar(&buf, ' ');
+		pgpa_format_index_target(&buf, entry->target->itarget);
+	}
+
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, ')');
+
+	return buf.data;
+}
+
+/*
+ * Set PGPA_TE_* flags on a set of trove entries.
+ */
+void
+pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
+{
+	int			i = -1;
+
+	while ((i = bms_next_member(indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+
+		entry->flags |= flags;
+	}
+}
+
+/*
+ * Add a new advice target to an existing pgpa_trove_slice object.
+ */
+static void
+pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+						pgpa_advice_tag_type tag,
+						pgpa_advice_target *target)
+{
+	pgpa_trove_entry *entry;
+
+	if (tslice->nused >= tslice->nallocated)
+	{
+		int			new_allocated;
+
+		new_allocated = tslice->nallocated * 2;
+		tslice->entries = repalloc_array(tslice->entries, pgpa_trove_entry,
+										 new_allocated);
+		tslice->nallocated = new_allocated;
+	}
+
+	entry = &tslice->entries[tslice->nused];
+	entry->tag = tag;
+	entry->target = target;
+	entry->flags = 0;
+
+	pgpa_trove_add_to_hash(tslice->hash, target, tslice->nused);
+
+	tslice->nused++;
+}
+
+/*
+ * Update the hash table for a newly-added advice target.
+ */
+static void
+pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash, pgpa_advice_target *target,
+					   int index)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	bool		found;
+
+	/* For non-identifiers, add entries for all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_trove_add_to_hash(hash, child_target, index);
+		}
+		return;
+	}
+
+	/* Sanity checks. */
+	Assert(target->rid.occurrence > 0);
+	Assert(target->rid.alias_name != NULL);
+
+	/* Add an entry for this relation identifier. */
+	key.alias_name = target->rid.alias_name;
+	key.partition_name = target->rid.partrel;
+	key.plan_name = target->rid.plan_name;
+	element = pgpa_trove_entry_insert(hash, key, &found);
+	element->indexes = bms_add_member(element->indexes, index);
+}
+
+/*
+ * Create and initialize a new pgpa_trove_slice object.
+ */
+static void
+pgpa_init_trove_slice(pgpa_trove_slice *tslice)
+{
+	/*
+	 * In an ideal world, we'll make tslice->nallocated big enough that the
+	 * array and hash table will be large enough to contain the number of
+	 * advice items in this trove slice, but a generous default value is not
+	 * good for performance, because pgpa_init_trove_slice() has to zero an
+	 * amount of memory proportional to tslice->nallocated. Hence, we keep the
+	 * starting value quite small, on the theory that advice strings will
+	 * often be relatively short.
+	 */
+	tslice->nallocated = 16;
+	tslice->nused = 0;
+	tslice->entries = palloc_array(pgpa_trove_entry, tslice->nallocated);
+	tslice->hash = pgpa_trove_entry_create(CurrentMemoryContext,
+										   tslice->nallocated, NULL);
+}
+
+/*
+ * Fast hash function for a key consisting of alias_name, partition_name,
+ * and plan_name.
+ */
+static uint32
+pgpa_trove_entry_hash_key(pgpa_trove_entry_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	/* alias_name may not be NULL */
+	sp_len = fasthash_accum_cstring(&hs, key.alias_name);
+
+	/* partition_name and plan_name, however, can be NULL */
+	if (key.partition_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.partition_name);
+	if (key.plan_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.plan_name);
+
+	/*
+	 * hashfn_unstable.h recommends using string length as tweak. It's not
+	 * clear to me what to do if there are multiple strings, so for now I'm
+	 * just using the total of all of the lengths.
+	 */
+	return fasthash_final32(&hs, sp_len);
+}
+
+/*
+ * Look for matching entries.
+ */
+static Bitmapset *
+pgpa_trove_slice_lookup(pgpa_trove_slice *tslice, pgpa_identifier *rid)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	Bitmapset  *result = NULL;
+
+	Assert(rid->occurrence >= 1);
+
+	key.alias_name = rid->alias_name;
+	key.partition_name = rid->partrel;
+	key.plan_name = rid->plan_name;
+
+	element = pgpa_trove_entry_lookup(tslice->hash, key);
+
+	if (element != NULL)
+	{
+		int			i = -1;
+
+		while ((i = bms_next_member(element->indexes, i)) >= 0)
+		{
+			pgpa_trove_entry *entry = &tslice->entries[i];
+
+			/*
+			 * We know that this target or one of its descendents matches the
+			 * identifier on the three key fields above, but we don't know
+			 * which descendent or whether the occurence and schema also
+			 * match.
+			 */
+			if (pgpa_identifier_matches_target(rid, entry->target))
+				result = bms_add_member(result, i);
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
new file mode 100644
index 00000000000..479c3f75778
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -0,0 +1,113 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.h
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_TROVE_H
+#define PGPA_TROVE_H
+
+#include "pgpa_ast.h"
+
+#include "nodes/bitmapset.h"
+
+typedef struct pgpa_trove pgpa_trove;
+
+/*
+ * Flags that can be set on a pgpa_trove_entry to indicate what happened when
+ * trying to plan using advice.
+ *
+ * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_TE_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_TE_MATCH_PARTIAL		0x0001
+#define PGPA_TE_MATCH_FULL			0x0002
+#define PGPA_TE_INAPPLICABLE		0x0004
+#define PGPA_TE_CONFLICTING			0x0008
+#define PGPA_TE_FAILED				0x0010
+
+/*
+ * Each entry in a trove of advice represents the application of a tag to
+ * a single target.
+ */
+typedef struct pgpa_trove_entry
+{
+	pgpa_advice_tag_type tag;
+	pgpa_advice_target *target;
+	int			flags;
+} pgpa_trove_entry;
+
+/*
+ * What kind of information does the caller want to find in a trove?
+ *
+ * PGPA_TROVE_LOOKUP_SCAN means we're looking for scan advice.
+ *
+ * PGPA_TROVE_LOOKUP_JOIN means we're looking for join-related advice.
+ * This includes join order advice, join method advice, and semijoin-uniqueness
+ * advice.
+ *
+ * PGPA_TROVE_LOOKUP_REL means we're looking for general advice about this
+ * a RelOptInfo that may correspond to either a scan or a join. This includes
+ * gather-related advice and partitionwise advice. Note that partitionwise
+ * advice might seem like join advice, but that's not a helpful way of viewing
+ * the matter because (1) partitionwise advice is also relevant at the scan
+ * level and (2) other types of join advice affect only what to do from
+ * join_path_setup_hook, but partitionwise advice affects what to do in
+ * joinrel_setup_hook.
+ */
+typedef enum pgpa_trove_lookup_type
+{
+	PGPA_TROVE_LOOKUP_JOIN,
+	PGPA_TROVE_LOOKUP_REL,
+	PGPA_TROVE_LOOKUP_SCAN
+} pgpa_trove_lookup_type;
+
+/*
+ * This struct is used to store the result of a trove lookup. For each member
+ * of "indexes", the entry at the corresponding offset within "entries" is one
+ * of the results.
+ */
+typedef struct pgpa_trove_result
+{
+	pgpa_trove_entry *entries;
+	Bitmapset  *indexes;
+} pgpa_trove_result;
+
+extern pgpa_trove *pgpa_build_trove(List *advice_items);
+extern void pgpa_trove_lookup(pgpa_trove *trove,
+							  pgpa_trove_lookup_type type,
+							  int nrids,
+							  pgpa_identifier *rids,
+							  pgpa_trove_result *result);
+extern void pgpa_trove_lookup_all(pgpa_trove *trove,
+								  pgpa_trove_lookup_type type,
+								  pgpa_trove_entry **entries,
+								  int *nentries);
+extern char *pgpa_cstring_trove_entry(pgpa_trove_entry *entry);
+extern void pgpa_trove_set_flags(pgpa_trove_entry *entries,
+								 Bitmapset *indexes, int flags);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
new file mode 100644
index 00000000000..4ed22291096
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -0,0 +1,890 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.c
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/plannodes.h"
+#include "parser/parsetree.h"
+
+static void pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+								  bool within_join_problem,
+								  pgpa_join_unroller *join_unroller,
+								  List *active_query_features,
+								  bool beneath_any_gather);
+static Bitmapset *pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+											 pgpa_unrolled_join *ujoin);
+
+static pgpa_query_feature *pgpa_add_feature(pgpa_plan_walker_context *walker,
+											pgpa_qf_type type,
+											Plan *plan);
+
+static void pgpa_qf_add_rti(List *active_query_features, Index rti);
+static void pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids);
+static void pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan,
+								  List *rtable);
+
+static bool pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+										   Index rtable_length,
+										   pgpa_identifier *rt_identifiers,
+										   pgpa_advice_target *target,
+										   bool toplevel);
+static bool pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+												  Index rtable_length,
+												  pgpa_identifier *rt_identifiers,
+												  pgpa_advice_target *target);
+static bool pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+									  pgpa_scan_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+										 pgpa_qf_type type,
+										 Bitmapset *relids);
+static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+									  pgpa_join_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+										   Bitmapset *relids);
+static Index pgpa_walker_get_rti(Index rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid);
+
+/*
+ * Top-level entrypoint for the plan tree walk.
+ *
+ * Populates walker based on a traversal of the Plan trees in pstmt.
+ */
+void
+pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt)
+{
+	ListCell   *lc;
+
+	/* Initialization. */
+	memset(walker, 0, sizeof(pgpa_plan_walker_context));
+	walker->pstmt = pstmt;
+
+	/* Walk the main plan tree. */
+	pgpa_walk_recursively(walker, pstmt->planTree, 0, NULL, NIL, false);
+
+	/* Main plan tree walk won't reach subplans, so walk those. */
+	foreach(lc, pstmt->subplans)
+	{
+		Plan	   *plan = lfirst(lc);
+
+		if (plan != NULL)
+			pgpa_walk_recursively(walker, plan, 0, NULL, NIL, false);
+	}
+}
+
+/*
+ * Main workhorse for the plan tree walk.
+ *
+ * If within_join_problem is true, we encountered a join at some higher level
+ * of the tree walk and haven't yet descended out of the portion of the plan
+ * tree that is part of that same join problem. We're no longer in the same
+ * join problem if (1) we cross into a different subquery or (2) we descend
+ * through an Append or MergeAppend node, below which any further joins would
+ * be partitionwise joins planned separately from the outer join problem.
+ *
+ * If join_unroller != NULL, the join unroller code expects us to find a join
+ * that should be unrolled into that object. This implies that we're within a
+ * join problem, but the reverse is not true: when we've traversed all the
+ * joins but are still looking for the scan that is the leaf of the join tree,
+ * join_unroller will be NULL but within_join_problem will be true.
+ *
+ * Each element of active_query_features corresponds to some item of advice
+ * that needs to enumerate all the relations it affects. We add RTIs we find
+ * during tree traversal to each of these query features.
+ *
+ * If beneath_any_gather == true, some higher level of the tree traversal found
+ * a Gather or Gather Merge node.
+ */
+static void
+pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+					  bool within_join_problem,
+					  pgpa_join_unroller *join_unroller,
+					  List *active_query_features,
+					  bool beneath_any_gather)
+{
+	pgpa_join_unroller *outer_join_unroller = NULL;
+	pgpa_join_unroller *inner_join_unroller = NULL;
+	bool		join_unroller_toplevel = false;
+	List	   *pushdown_query_features = NIL;
+	ListCell   *lc;
+	List	   *extraplans = NIL;
+	List	   *elided_nodes = NIL;
+
+	Assert(within_join_problem || join_unroller == NULL);
+
+	/*
+	 * If this is a Gather or Gather Merge node, directly add it to the list
+	 * of currently-active query features.
+	 *
+	 * Otherwise, check the future_query_features list to see whether this was
+	 * previously identified as a plan node that needs to be treated as a
+	 * query feature.
+	 *
+	 * Note that the caller also has a copy to active_query_features, so we
+	 * can't destructively modify it without making a copy.
+	 */
+	if (IsA(plan, Gather))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER, plan));
+		beneath_any_gather = true;
+	}
+	else if (IsA(plan, GatherMerge))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER_MERGE, plan));
+		beneath_any_gather = true;
+	}
+	else
+	{
+		foreach_ptr(pgpa_query_feature, qf, walker->future_query_features)
+		{
+			if (qf->plan == plan)
+			{
+				active_query_features = list_copy(active_query_features);
+				active_query_features = lappend(active_query_features, qf);
+				walker->future_query_features =
+					list_delete_ptr(walker->future_query_features, plan);
+				break;
+			}
+		}
+	}
+
+	/*
+	 * Find all elided nodes for this Plan node.
+	 */
+	foreach_node(ElidedNode, n, walker->pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_nodes = lappend(elided_nodes, n);
+	}
+
+	/* If we found any elided_nodes, handle them. */
+	if (elided_nodes != NIL)
+	{
+		int			num_elided_nodes = list_length(elided_nodes);
+		ElidedNode *last_elided_node;
+
+		/*
+		 * RTIs for the final -- and thus logically uppermost -- elided node
+		 * should be collected for query features passed down by the caller.
+		 * However, elided nodes act as barriers to query features, which
+		 * means that (1) the remaining elided nodes, if any, should be
+		 * ignored for purposes of query features and (2) the list of active
+		 * query features should be reset to empty so that we do not add RTIs
+		 * from the plan node that is logically beneath the elided node to the
+		 * query features passed down from the caller.
+		 */
+		last_elided_node = list_nth(elided_nodes, num_elided_nodes - 1);
+		pgpa_qf_add_rtis(active_query_features,
+						 pgpa_filter_out_join_relids(last_elided_node->relids,
+													 walker->pstmt->rtable));
+		active_query_features = NIL;
+
+		/*
+		 * If we're within a join problem, the join_unroller is responsible
+		 * for building the scan for the final elided node, so throw it out.
+		 */
+		if (within_join_problem)
+			elided_nodes = list_truncate(elided_nodes, num_elided_nodes - 1);
+
+		/* Build scans for all (or the remaining) elided nodes. */
+		foreach_node(ElidedNode, elided_node, elided_nodes)
+		{
+			(void) pgpa_build_scan(walker, plan, elided_node,
+								   beneath_any_gather, within_join_problem);
+		}
+
+		/*
+		 * If there were any elided nodes, then everything beneath those nodes
+		 * is not part of the same join problem.
+		 *
+		 * In more detail, if an Append or MergeAppend was elided, then a
+		 * partitionwise join was chosen and only a single child survived; if
+		 * a SubqueryScan was elided, the subquery was planned without
+		 * flattening it into the parent.
+		 */
+		within_join_problem = false;
+		join_unroller = NULL;
+	}
+
+	/*
+	 * If we're within a join problem, the join unroller is responsible for
+	 * building any required scan for this node. If not, we do it here.
+	 */
+	if (!within_join_problem)
+		(void) pgpa_build_scan(walker, plan, NULL, beneath_any_gather, false);
+
+	/*
+	 * If this join needs to unrolled but there's no join unroller already
+	 * available, create one.
+	 */
+	if (join_unroller == NULL && pgpa_is_join(plan))
+	{
+		join_unroller = pgpa_create_join_unroller();
+		join_unroller_toplevel = true;
+		within_join_problem = true;
+	}
+
+	/*
+	 * If this join is to be unrolled, pgpa_unroll_join() will return the join
+	 * unroller object that should be passed down when we recurse into the
+	 * outer and inner sides of the plan.
+	 */
+	if (join_unroller != NULL)
+		pgpa_unroll_join(walker, plan, beneath_any_gather, join_unroller,
+						 &outer_join_unroller, &inner_join_unroller);
+
+	/* Add RTIs from the plan node to all active query features. */
+	pgpa_qf_add_plan_rtis(active_query_features, plan, walker->pstmt->rtable);
+
+	/*
+	 * Recurse into the outer and inner subtrees.
+	 *
+	 * As an exception, if this is a ForeignScan, don't recurse. postgres_fdw
+	 * sometimes stores an EPQ recheck plan in plan->leftree, but that's going
+	 * to mention the same set of relations as the ForeignScan itself, and we
+	 * have no way to emit advice targeting the EPQ case vs. the non-EPQ case.
+	 * Moreover, it's not entirely clear what other FDWs might do with the
+	 * left and right subtrees. Maybe some better handling is needed here, but
+	 * for now, we just punt.
+	 */
+	if (!IsA(plan, ForeignScan))
+	{
+		if (plan->lefttree != NULL)
+			pgpa_walk_recursively(walker, plan->lefttree, within_join_problem,
+								  outer_join_unroller, active_query_features,
+								  beneath_any_gather);
+		if (plan->righttree != NULL)
+			pgpa_walk_recursively(walker, plan->righttree, within_join_problem,
+								  inner_join_unroller, active_query_features,
+								  beneath_any_gather);
+	}
+
+	/*
+	 * If we created a join unroller up above, then it's also our join to use
+	 * it to build the final pgpa_unrolled_join, and to destroy the object.
+	 */
+	if (join_unroller_toplevel)
+	{
+		pgpa_unrolled_join *ujoin;
+
+		ujoin = pgpa_build_unrolled_join(walker, join_unroller);
+		walker->toplevel_unrolled_joins =
+			lappend(walker->toplevel_unrolled_joins, ujoin);
+		pgpa_destroy_join_unroller(join_unroller);
+		(void) pgpa_process_unrolled_join(walker, ujoin);
+	}
+
+	/*
+	 * Some plan types can have additional children. Nodes like Append that
+	 * can have any number of children store them in a List; a SubqueryScan
+	 * just has a field for a single additional Plan.
+	 */
+	switch (nodeTag(plan))
+	{
+		case T_Append:
+			{
+				Append	   *aplan = (Append *) plan;
+
+				extraplans = aplan->appendplans;
+				if (bms_is_empty(aplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_MergeAppend:
+			{
+				MergeAppend *maplan = (MergeAppend *) plan;
+
+				extraplans = maplan->mergeplans;
+				if (bms_is_empty(maplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_BitmapAnd:
+			extraplans = ((BitmapAnd *) plan)->bitmapplans;
+			break;
+		case T_BitmapOr:
+			extraplans = ((BitmapOr *) plan)->bitmapplans;
+			break;
+		case T_SubqueryScan:
+
+			/*
+			 * We don't pass down active_query_features across here, because
+			 * those are specific to a subquery level.
+			 */
+			pgpa_walk_recursively(walker, ((SubqueryScan *) plan)->subplan,
+								  0, NULL, NIL, beneath_any_gather);
+			break;
+		case T_CustomScan:
+			extraplans = ((CustomScan *) plan)->custom_plans;
+			break;
+		default:
+			break;
+	}
+
+	/* If we found a list of extra children, iterate over it. */
+	foreach(lc, extraplans)
+	{
+		Plan	   *subplan = lfirst(lc);
+
+		pgpa_walk_recursively(walker, subplan, 0, NULL, pushdown_query_features,
+							  beneath_any_gather);
+	}
+}
+
+/*
+ * Perform final processing of a newly-constructed pgpa_unrolled_join. This
+ * only needs to be called for toplevel pgpa_unrolled_join objects, since it
+ * recurses to sub-joins as needed.
+ *
+ * Our goal is to add the set of inner relids to the relevant join_strategies
+ * list, and to do the same for any sub-joins. To that end, the return value
+ * is the set of relids found beneath the inner side of the join, but it is
+ * expected that the toplevel caller will ignore this.
+ */
+static Bitmapset *
+pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+						   pgpa_unrolled_join *ujoin)
+{
+	Bitmapset  *all_relids = NULL;
+
+	for (int k = 0; k < ujoin->ninner; ++k)
+	{
+		pgpa_join_member *member = &ujoin->inner[k];
+		Bitmapset  *relids;
+
+		if (member->unrolled_join != NULL)
+			relids = pgpa_process_unrolled_join(walker,
+												member->unrolled_join);
+		else
+		{
+			Assert(member->scan != NULL);
+			relids = member->scan->relids;
+		}
+		walker->join_strategies[ujoin->strategy[k]] =
+			lappend(walker->join_strategies[ujoin->strategy[k]], relids);
+		all_relids = bms_add_members(all_relids, relids);
+	}
+
+	return all_relids;
+}
+
+/*
+ * Arrange for the given plan node to be treated as a query feature when the
+ * tree walk reaches it.
+ *
+ * Make sure to only use this for nodes that the tree walk can't have reached
+ * yet!
+ */
+void
+pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+						pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = pgpa_add_feature(walker, type, plan);
+
+	walker->future_query_features =
+		lappend(walker->future_query_features, qf);
+}
+
+/*
+ * Return the last of any elided nodes associated with this plan node ID.
+ *
+ * The last elided node is the one that would have been uppermost in the plan
+ * tree had it not been removed during setrefs processig.
+ */
+ElidedNode *
+pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan)
+{
+	ElidedNode *elided_node = NULL;
+
+	foreach_node(ElidedNode, n, pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_node = n;
+	}
+
+	return elided_node;
+}
+
+/*
+ * Certain plan nodes can refer to a set of RTIs. Extract and return the set.
+ */
+Bitmapset *
+pgpa_relids(Plan *plan)
+{
+	if (IsA(plan, Result))
+		return ((Result *) plan)->relids;
+	else if (IsA(plan, ForeignScan))
+		return ((ForeignScan *) plan)->fs_relids;
+	else if (IsA(plan, Append))
+		return ((Append *) plan)->apprelids;
+	else if (IsA(plan, MergeAppend))
+		return ((MergeAppend *) plan)->apprelids;
+
+	return NULL;
+}
+
+/*
+ * Extract the scanned RTI from a plan node.
+ *
+ * Returns 0 if there isn't one.
+ */
+Index
+pgpa_scanrelid(Plan *plan)
+{
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+		case T_SubqueryScan:
+		case T_FunctionScan:
+		case T_TableFuncScan:
+		case T_ValuesScan:
+		case T_CteScan:
+		case T_NamedTuplestoreScan:
+		case T_WorkTableScan:
+		case T_ForeignScan:
+		case T_CustomScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+			return ((Scan *) plan)->scanrelid;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
+ */
+Bitmapset *
+pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	Bitmapset  *result = NULL;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind != RTE_JOIN)
+			result = bms_add_member(result, rti);
+	}
+
+	return result;
+}
+
+/*
+ * Create a pgpa_query_feature and add it to the list of all query features
+ * for this plan.
+ */
+static pgpa_query_feature *
+pgpa_add_feature(pgpa_plan_walker_context *walker,
+				 pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = palloc0_object(pgpa_query_feature);
+
+	qf->type = type;
+	qf->plan = plan;
+
+	walker->query_features[qf->type] =
+		lappend(walker->query_features[qf->type], qf);
+
+	return qf;
+}
+
+/*
+ * Add a single RTI to each active query feature.
+ */
+static void
+pgpa_qf_add_rti(List *active_query_features, Index rti)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_member(qf->relids, rti);
+	}
+}
+
+/*
+ * Add a set of RTIs to each active query feature.
+ */
+static void
+pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_members(qf->relids, relids);
+	}
+}
+
+/*
+ * Add RTIs directly contained in a plan node to each active query feature,
+ * but filter out any join RTIs, since advice doesn't mention those.
+ */
+static void
+pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan, List *rtable)
+{
+	Bitmapset  *relids;
+	Index		rti;
+
+	if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		relids = pgpa_filter_out_join_relids(relids, rtable);
+		pgpa_qf_add_rtis(active_query_features, relids);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+		pgpa_qf_add_rti(active_query_features, rti);
+}
+
+/*
+ * If we generated plan advice using the provided walker object and array
+ * of identifiers, would we generate the specified tag/target combination?
+ *
+ * If yes, the plan conforms to the advice; if no, it does not. Note that
+ * we have know way of knowing whether the planner was forced to emit a plan
+ * that conformed to the advice or just happened to do so.
+ */
+bool
+pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+						 pgpa_identifier *rt_identifiers,
+						 pgpa_advice_tag_type tag,
+						 pgpa_advice_target *target)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	Bitmapset  *relids = NULL;
+
+	if (tag == PGPA_TAG_JOIN_ORDER)
+	{
+		foreach_ptr(pgpa_unrolled_join, ujoin, walker->toplevel_unrolled_joins)
+		{
+			if (pgpa_walker_join_order_matches(ujoin, rtable_length,
+											   rt_identifiers, target, true))
+				return true;
+		}
+
+		return false;
+	}
+
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		Index		rti;
+
+		rti = pgpa_walker_get_rti(rtable_length, rt_identifiers, &target->rid);
+		relids = bms_make_singleton(rti);
+	}
+	else
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			Index		rti;
+
+			Assert(child_target->ttype == PGPA_TARGET_IDENTIFIER);
+			rti = pgpa_compute_rti_from_identifier(rtable_length,
+												   rt_identifiers,
+												   &child_target->rid);
+			if (rti == 0)
+				elog(ERROR, "cannot determine RTI for advice target");
+			relids = bms_add_member(relids, rti);
+		}
+	}
+
+	switch (tag)
+	{
+		case PGPA_TAG_JOIN_ORDER:
+			/* should have been handled above */
+			pg_unreachable();
+			break;
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_BITMAP_HEAP,
+											 relids);
+		case PGPA_TAG_FOREIGN_JOIN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_FOREIGN,
+											 relids);
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX_ONLY,
+											 relids);
+		case PGPA_TAG_INDEX_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX,
+											 relids);
+		case PGPA_TAG_PARTITIONWISE:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_PARTITIONWISE,
+											 relids);
+		case PGPA_TAG_SEQ_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_SEQ,
+											 relids);
+		case PGPA_TAG_TID_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_TID,
+											 relids);
+		case PGPA_TAG_GATHER:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER,
+												relids);
+		case PGPA_TAG_GATHER_MERGE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER_MERGE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_NON_UNIQUE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_UNIQUE,
+												relids);
+		case PGPA_TAG_HASH_JOIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_HASH_JOIN,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_PLAIN,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MEMOIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_PLAIN,
+											 relids);
+		case PGPA_TAG_NO_GATHER:
+			return pgpa_walker_contains_no_gather(walker, relids);
+	}
+
+	/* should not get here */
+	return false;
+}
+
+/*
+ * Does an unrolled join match the join order specified by an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+							   Index rtable_length,
+							   pgpa_identifier *rt_identifiers,
+							   pgpa_advice_target *target,
+							   bool toplevel)
+{
+	int		nchildren = list_length(target->children);
+
+	Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	/* At toplevel, we allow a prefix match. */
+	if (toplevel)
+	{
+		if (nchildren > ujoin->ninner + 1)
+			return false;
+	}
+	else
+	{
+		if (nchildren != ujoin->ninner + 1)
+			return false;
+	}
+
+	/* Outermost rel must match. */
+	if (!pgpa_walker_join_order_matches_member(&ujoin->outer,
+											   rtable_length,
+											   rt_identifiers,
+											   linitial(target->children)))
+		return false;
+
+	/* Each inner rel must match. */
+	for (int n = 0; n < nchildren - 1; ++n)
+	{
+		pgpa_advice_target *child_target = list_nth(target->children, n + 1);
+
+		if (!pgpa_walker_join_order_matches_member(&ujoin->inner[n],
+												   rtable_length,
+												   rt_identifiers,
+												   child_target))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Does one member of an unrolled join match an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+									  Index rtable_length,
+									  pgpa_identifier *rt_identifiers,
+									  pgpa_advice_target *target)
+{
+	Bitmapset  *relids = NULL;
+
+	if (member->unrolled_join != NULL)
+	{
+		if (target->ttype != PGPA_TARGET_ORDERED_LIST)
+			return false;
+		return pgpa_walker_join_order_matches(member->unrolled_join,
+											  rtable_length,
+											  rt_identifiers,
+											  target,
+											  false);
+	}
+
+	Assert(member->scan != NULL);
+	switch (target->ttype)
+	{
+		case PGPA_TARGET_ORDERED_LIST:
+			/* Could only match an unrolled join */
+			return false;
+
+		case PGPA_TARGET_UNORDERED_LIST:
+			{
+				foreach_ptr(pgpa_advice_target, child_target, target->children)
+				{
+					Index		rti;
+
+					rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+											  &child_target->rid);
+					relids = bms_add_member(relids, rti);
+				}
+				break;
+			}
+
+		case PGPA_TARGET_IDENTIFIER:
+			{
+				Index		rti;
+
+				rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+										  &target->rid);
+				relids = bms_make_singleton(rti);
+				break;
+			}
+	}
+
+	return bms_equal(member->scan->relids, relids);
+}
+
+/*
+ * Does this walker say that the given scan strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+						  pgpa_scan_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *scans = walker->scans[strategy];
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		/*
+		 * XXX. If this is index-related advice, we should also validate that
+		 * the advice target's index target matches the Plan tree.
+		 */
+		if (bms_equal(scan->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does this walker say that the given query feature applies to the given
+ * relid set?
+ */
+static bool
+pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+							 pgpa_qf_type type,
+							 Bitmapset *relids)
+{
+	List	   *query_features = walker->query_features[type];
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (bms_equal(qf->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given join strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+						  pgpa_join_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *join_strategies = walker->join_strategies[strategy];
+
+	foreach_ptr(Bitmapset, jsrelids, join_strategies)
+	{
+		if (bms_equal(jsrelids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given relids should be marked as NO_GATHER?
+ */
+static bool
+pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+							   Bitmapset *relids)
+{
+	return bms_is_subset(relids, walker->no_gather_scans);
+}
+
+/*
+ * Convenience function to convert a relation identifier to an RTI.
+ *
+ * We throw an error here because we expect this to be used on system-generated
+ * advice. Hence, failure here indicates an advice generation bug.
+ */
+static Index
+pgpa_walker_get_rti(Index rtable_length,
+					pgpa_identifier *rt_identifiers,
+					pgpa_identifier *rid)
+{
+	Index		rti;
+
+	rti = pgpa_compute_rti_from_identifier(rtable_length,
+										   rt_identifiers,
+										   rid);
+	if (rti == 0)
+		elog(ERROR, "cannot determine RTI for advice target");
+	return rti;
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
new file mode 100644
index 00000000000..f244f4428a5
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -0,0 +1,122 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.h
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_WALKER_H
+#define PGPA_WALKER_H
+
+#include "pgpa_ast.h"
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+
+/*
+ * We use the term "query feature" to refer to plan nodes that are interesting
+ * in the following way: to generate advice, we'll need to know the set of
+ * same-subquery, non-join RTIs occuring at or below that plan node, without
+ * admixture of parent and child RTIs.
+ *
+ * For example, Gather nodes, desiginated by PGPAQF_GATHER, and Gather Merge
+ * nodes, designated by PGPAQF_GATHER_MERGE, are query features, because we'll
+ * want to admit some kind of advice that describes the portion of the plan
+ * tree that appears beneath those nodes.
+ *
+ * Each semijoin can be implemented either by directly performing a semijoin,
+ * or by making one side unique and then performing a normal join. Either way,
+ * we use a query feature to notice what decision was made, so that we can
+ * describe it by enumerating the RTIs on that side of the join.
+ *
+ * To elaborate on the "no admixture of parent and child RTIs" rule, in all of
+ * these cases, if the entirety of an inheritance hierarchy appears beneath
+ * the query feature, we only want to name the parent table. But it's also
+ * possible to have cases where we must name child tables. This is particularly
+ * likely to happen when partitionwise join is in use, but could happen for
+ * Gather or Gather Merge even without that, if one of those appears below
+ * an Append or MergeAppend node for a single table.
+ */
+typedef enum pgpa_qf_type
+{
+	PGPAQF_GATHER,
+	PGPAQF_GATHER_MERGE,
+	PGPAQF_SEMIJOIN_NON_UNIQUE,
+	PGPAQF_SEMIJOIN_UNIQUE
+	/* update NUM_PGPA_QF_TYPES if you add anything here */
+} pgpa_qf_type;
+
+#define NUM_PGPA_QF_TYPES ((int) PGPAQF_SEMIJOIN_UNIQUE + 1)
+
+/*
+ * For each query feature, we keep track of the feature type and the set of
+ * relids that we found underneath the relevant plan node. See the comments
+ * on pgpa_qf_type, above, for additional details.
+ */
+typedef struct pgpa_query_feature
+{
+	pgpa_qf_type type;
+	Plan	   *plan;
+	Bitmapset  *relids;
+} pgpa_query_feature;
+
+/*
+ * Context object for plan tree walk.
+ *
+ * pstmt is the PlannedStmt we're studying.
+ *
+ * scans is an array of lists of pgpa_scan objects. The array is indexed by
+ * the scan's pgpa_scan_strategy.
+ *
+ * no_gather_scans is the set of scan RTIs that do not appear beneath any
+ * Gather or Gather Merge node.
+ *
+ * toplevel_unrolled_joins is a list of all pgpa_unrolled_join objects that
+ * are not a child of some other pgpa_unrolled_join.
+ *
+ * join_strategy is an array of lists of Bitmapset objects. Each Bitmapset
+ * is the set of relids that appears on the inner side of some join (excluding
+ * RTIs from partition children and subqueries). The array is indexed by
+ * pgpa_join_strategy.
+ *
+ * query_features is an array lists of pgpa_query_feature objects, indexed
+ * by pgpa_qf_type.
+ *
+ * future_query_features is only used during the plan tree walk and should
+ * be empty when the tree walk concludes. It is a list of pgpa_query_feature
+ * objects for Plan nodes that the plan tree walk has not yet encountered;
+ * when encountered, they will be moved to the list of active query features
+ * that is propagated via the call stack.
+ */
+typedef struct pgpa_plan_walker_context
+{
+	PlannedStmt *pstmt;
+	List	   *scans[NUM_PGPA_SCAN_STRATEGY];
+	Bitmapset  *no_gather_scans;
+	List	   *toplevel_unrolled_joins;
+	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
+	List	   *query_features[NUM_PGPA_QF_TYPES];
+	List	   *future_query_features;
+} pgpa_plan_walker_context;
+
+extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
+							 PlannedStmt *pstmt);
+
+extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+									pgpa_qf_type type,
+									Plan *plan);
+
+extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
+extern Bitmapset *pgpa_relids(Plan *plan);
+extern Index pgpa_scanrelid(Plan *plan);
+extern Bitmapset *pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable);
+
+extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+									 pgpa_identifier *rt_identifiers,
+									 pgpa_advice_tag_type tag,
+									 pgpa_advice_target *target);
+
+#endif
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
new file mode 100644
index 00000000000..58280043913
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_order.sql b/contrib/pg_plan_advice/sql/join_order.sql
new file mode 100644
index 00000000000..5aa2fc62d34
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_order.sql
@@ -0,0 +1,96 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
+
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/sql/join_strategy.sql b/contrib/pg_plan_advice/sql/join_strategy.sql
new file mode 100644
index 00000000000..8eb823f1c0e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_strategy.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/local_collector.sql b/contrib/pg_plan_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..08502573c8f
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/local_collector.sql
@@ -0,0 +1,41 @@
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_plan_advice/sql/partitionwise.sql b/contrib/pg_plan_advice/sql/partitionwise.sql
new file mode 100644
index 00000000000..e42c0611760
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/partitionwise.sql
@@ -0,0 +1,78 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
new file mode 100644
index 00000000000..25416a75f46
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -0,0 +1,195 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+COMMIT;
+
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+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 > 0;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+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;
+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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+COMMIT;
+
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/syntax.sql b/contrib/pg_plan_advice/sql/syntax.sql
new file mode 100644
index 00000000000..8bc1b71bebe
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/syntax.sql
@@ -0,0 +1,42 @@
+LOAD 'pg_plan_advice';
+
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+SET pg_plan_advice.advice = '()';
+SET pg_plan_advice.advice = '123';
+
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
diff --git a/contrib/pg_plan_advice/t/001_regress.pl b/contrib/pg_plan_advice/t/001_regress.pl
new file mode 100644
index 00000000000..735f54d57ec
--- /dev/null
+++ b/contrib/pg_plan_advice/t/001_regress.pl
@@ -0,0 +1,147 @@
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_plan_advice to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+#
+# We run with pg_plan_advice.shared_collection_limit set to ensure that the
+# plan tree walker code runs against every query in the regression tests. If
+# we're unable to properly analyze any of those plan trees, this test should fail.
+#
+# We set pg_plan_advice.advice to an advice string that will cause the advice
+# trove to be populated with a few entries of various sorts, but which we do
+# not expect to match anything in the regression test queries. This way, the
+# planner hooks will be called, improving code coverage, but no plans should
+# actually change.
+#
+# pg_plan_advice.always_explain_supplied_advice=false is needed to avoid breaking
+# regression test queries that use EXPLAIN. In the real world, it seems like
+# users will want EXPLAIN output to show supplied advice so that it's clear
+# whether normal planner behavior has been altered, but here that's undesirable.
+$node->append_conf('postgresql.conf', <<EOM);
+pg_plan_advice.shared_collection_limit=1000000
+shared_preload_libraries=pg_plan_advice
+pg_plan_advice.advice='SEQ_SCAN(entirely_fictitious) HASH_JOIN(total_fabrication) GATHER(completely_imaginary)'
+pg_plan_advice.always_explain_supplied_advice=false
+EOM
+$node->start;
+
+my $srcdir = abs_path("../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+# Create the extension so we can access the collector
+$node->safe_psql('postgres', 'CREATE EXTENSION pg_plan_advice');
+
+# Verify that a large amount of advice was collected
+my $all_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice();
+EOM
+cmp_ok($all_query_count, '>', 35000, "copious advice collected");
+
+# Verify that lots of different advice strings were collected
+my $distinct_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM
+	(SELECT DISTINCT advice FROM pg_get_collected_shared_advice());
+EOM
+cmp_ok($distinct_query_count, '>', 3000, "diverse advice collected");
+
+# We want to test for the presence of our known tags in the collected advice.
+# Put all tags into the hash that follows; map any tags that aren't tested
+# by the core regression tests to 0, and others to 1.
+my %tag_map = (
+	BITMAP_HEAP_SCAN => 1,
+	FOREIGN_JOIN => 0,
+	GATHER => 1,
+	GATHER_MERGE => 1,
+	HASH_JOIN => 1,
+	INDEX_ONLY_SCAN => 1,
+	INDEX_SCAN => 1,
+	JOIN_ORDER => 1,
+	MERGE_JOIN_MATERIALIZE => 1,
+	MERGE_JOIN_PLAIN => 1,
+	NESTED_LOOP_MATERIALIZE => 1,
+	NESTED_LOOP_MEMOIZE => 1,
+	NESTED_LOOP_PLAIN => 1,
+	NO_GATHER => 1,
+	PARTITIONWISE => 1,
+	SEMIJOIN_NON_UNIQUE => 1,
+	SEMIJOIN_UNIQUE => 1,
+	SEQ_SCAN => 1,
+	TID_SCAN => 1,
+);
+for my $tag (sort keys %tag_map)
+{
+	my $checkit = $tag_map{$tag};
+
+	# Search for the given tag. This is not entirely robust: it could get thrown
+	# off by a table alias such as "FOREIGN_JOIN(", but that probably won't
+	# happen in the core regression tests.
+	my $tag_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice()
+	WHERE advice LIKE '%$tag(%'
+EOM
+
+	# Check that the tag got a non-trivial amount of use, unless told otherwise.
+	cmp_ok($tag_count, '>', 10, "multiple uses of $tag") if $checkit;
+
+	# Regardless, note the exact count in the log, for human consumption.
+	note("found $tag_count advice strings containing $tag");
+}
+
+# Trigger a partial cleanup of the shared advice collector, and then a full
+# cleanup.
+$node->safe_psql('postgres', <<EOM);
+SET pg_plan_advice.shared_collection_limit=500;
+SELECT * FROM pg_clear_collected_shared_advice();
+EOM
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 53a70c210a7..1858691b24c 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3943,6 +3943,43 @@ pg_wc_probefunc
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgpa_collected_advice
+pgpa_advice_item
+pgpa_advice_tag_type
+pgpa_advice_target
+pgpa_identifier
+pgpa_index_target
+pgpa_index_type
+pgpa_itm_type
+pgpa_join_class
+pgpa_join_member
+pgpa_join_state
+pgpa_join_strategy
+pgpa_join_unroller
+pgpa_local_advice
+pgpa_local_advice_chunk
+pgpa_output_context
+pgpa_plan_walker_context
+pgpa_planner_state
+pgpa_qf_type
+pgpa_query_feature
+pgpa_ri_checker
+pgpa_ri_checker_key
+pgpa_scan
+pgpa_scan_strategy
+pgpa_shared_advice
+pgpa_shared_advice_chunk
+pgpa_shared_state
+pgpa_target_type
+pgpa_trove
+pgpa_trove_entry
+pgpa_trove_entry_element
+pgpa_trove_entry_hash
+pgpa_trove_entry_key
+pgpa_trove_lookup_type
+pgpa_trove_result
+pgpa_trove_slice
+pgpa_unrolled_join
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-08 20:39  Greg Burd <[email protected]>
  parent: Robert Haas <[email protected]>
  3 siblings, 1 reply; 133+ messages in thread

From: Greg Burd @ 2025-12-08 20:39 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>


On Fri, Dec 5, 2025, at 2:57 PM, Robert Haas wrote:
> 014f9a831a320666bf2195949f41710f970c54ad removes the need for what was
> previously 0004, so here is a new patch series with that dropped, to
> avoid confusing cfbot or human reviewers.
>
> -- 
> Robert Haas
> EDB: http://www.enterprisedb.com

Hey Robert,

Thanks for working on this!  I think the idea has merit, I hope it lands sometime soon.

I've worked on extending PostgreSQL's planner for a JSONB-like document system. The new query shapes frequently caused mysterious planner issues. Having the ability to:

1. Dump detailed planner decisions for comparison during development
2. Record planner choices from customer databases to reproduce their issues
3. Apply fixed plans to specific queries as a quick customer workaround while addressing root causes in later releases

...would have been invaluable.

Yes, there's danger here ("with great power comes great responsibility"), but I see this as providing more information to make better decisions when working with the black art of planner logic.

ORM Query Variation Challenge

Jakob's point about "crazy ORMs" is important. ORMs generate queries with minor variations that should ideally match the same plan advice. I need to study the plan advice matching logic more deeply to understand how it handles query variations.

This reminded me of Erlang/Elixir's "parse transforms" - compiled code forms an AST that registered transforms can modify via pattern matching. The concept might be relevant here: pattern-matching portions of query ASTs to apply advice despite syntactic variations. I'd need to think more about whether this intersects well with the current design or if it's impractical, but it's worth exploring.

> Attachments:
> * v5-0001-Store-information-about-range-table-flattening-in.patch

contrib/pg_overexplain/pg_overexplain.c

+               /* Advance to next SubRTInfo, if it's time. */
+               if (lc_subrtinfo != NULL)
+               {
+                       next_rtinfo = lfirst(lc_subrtinfo);
+                       if (rti > next_rtinfo->rtoffset)

Should the test be >= not >? Unless I am I reading this wrong, when rti == rtoffset, that's the first entry of the new subplan's range table. That would mean that the current logic skips displaying the subplan name for the first RTE of each subplan.

in src/include/nodes/plannodes.h there is:

+typedef struct SubPlanRTInfo
+{
+       NodeTag         type;
+       const char *plan_name;
+       Index           rtoffset;
+       bool            dummy;
+} SubPlanRTInfo;

This is where I get confused, if rtoffset is an Index, then the comparison (above) in pg_overexplain uses rti > next_rtinfo->rtoffset where rti starts at 1. If rtoffset is 0 for the first subplan, the logic might be off-by-one, no?

> This commit teaches pg_overexplain'e RANGE_TABLE option to make use
Minor nit in the commit message, "pg_overexplain'e" should be "pg_overexplain's"

> * v5-0002-Store-information-about-elided-nodes-in-the-final.patch

+/*
+ * Record some details about a node removed from the plan during setrefs
+ * procesing, for the benefit of code trying to reconstruct planner decisions
+ * from examination of the final plan tree.
+ */

Nit, "procesing" should be "processing"

> * v5-0003-Store-information-about-Append-node-consolidation.patch

src/backend/optimizer/path/allpaths.c

/* Now consider each interesting sort ordering */
foreach(lcp, all_child_pathkeys)
{
        List       *subpaths = NIL;
        bool            subpaths_valid = true;
+       List       *subpath_cars = NIL;
        List       *startup_subpaths = NIL;
        bool            startup_subpaths_valid = true;
+       List       *startup_subpath_cars = NIL;
        List       *partial_subpaths = NIL;
+       List       *partial_subpath_cars = NIL;
        List       *pa_partial_subpaths = NIL;
        List       *pa_nonpartial_subpaths = NIL;
+       List       *pa_subpath_cars = NIL;

I find "cars" a bit cryptic (albeit clever), I think I've decoded it properly and it stands for "child_append_relid_sets", correct?  Could you add a comment or use a clearer name like subpath_child_relids or consolidated_relid_sets?


+accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
+                                                 List **child_append_relid_sets)
 {
        if (IsA(path, AppendPath))
        {
@@ -2219,6 +2256,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
                if (!apath->path.parallel_aware || apath->first_partial_path == 0)
                {
                        *subpaths = list_concat(*subpaths, apath->subpaths);
+                       *child_append_relid_sets =
+                               lappend(*child_append_relid_sets, path->parent->relids);

Is it possible that when pulling up multiple subpaths from an AppendPath, only ONE relid set is added to child_append_relid_sets, but MULTIPLE paths are added to subpaths?  If so, that would break the correspondence between the lists which would be bad, right?

src/include/nodes/pathnodes.h
+ * Whenever accumulate_append_subpath() allows us to consolidate multiple
+ * levels of Append paths are consolidated down to one, we store the RTI
+ * sets for the omitted paths in child_append_relid_sets. This is not necessary
+ * for planning or execution; we do it for the benefit of code that wants
+ * to inspect the final plan and understand how it came to be.

Minor: "paths are consolidated" is redundant, should be "paths consolidated" or "allows us to consolidate".

> * v5-0004-Allow-for-plugin-control-over-path-generation-str.patch

src/backend/optimizer/path/costsize.c
+       else
+               enable_mask |= PGS_CONSIDER_NONPARTIAL;

-       path->disabled_nodes = enable_seqscan ? 0 : 1;
+       path->disabled_nodes =
+               (baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;

When parallel_workers > 0 the path is partial and doesn't need PGS_CONSIDER_NONPARTIAL. But if parallel_workers == 0, it's non-partial and DOES need it, right?  Would this mean that non-partial paths can be disabled even when the scan type itself (e.g., PGS_SEQSCAN) is enabled?  Intentional?

> * v5-0005-WIP-Add-pg_plan_advice-contrib-module.patch

It seems this is still WIP with a solid start, I'm not going to dig too much into it. :)

Keep it up, best.

-greg





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-09 01:18  Jacob Champion <[email protected]>
  parent: Robert Haas <[email protected]>
  3 siblings, 1 reply; 133+ messages in thread

From: Jacob Champion @ 2025-12-09 01:18 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

Hello,

On Fri, Dec 5, 2025 at 11:57 AM Robert Haas <[email protected]> wrote:
> 014f9a831a320666bf2195949f41710f970c54ad removes the need for what was
> previously 0004, so here is a new patch series with that dropped, to
> avoid confusing cfbot or human reviewers.

I really like this idea! Telling the planner, "if you need to make a
decision for [this thing], choose [this way]," seems to be a really
nice way of sidestepping many of the concerns with "user control".

I've started an attempt to throw a fuzzer at this, because I'm pretty
useless when it comes to planner/optimizer review. I don't really know
what the overall fuzzing strategy is going to be, given the multiple
complicated inputs that have to be constructed and somehow correlated
with each other, but I'll try to start small and expand:

a) fuzz the parser first, because it's easy and we can get interesting inputs
b) fuzz the AST utilities, seeded with "successful" corpus members from a)
c) stare really hard at the corpus of b) and figure out how to
usefully mutate a PlannedStmt with it
d) use c) to fuzz pgpa_plan_walker, then pgpa_output_advice, then...?

I'm in the middle of an implementation of b) now, and it noticed the
following code (which probably bodes well for the fuzzer itself!):

>        if (rid->partnsp == NULL)
>            result = psprintf("%s/%s", result,
>                              quote_identifier(rid->partnsp));

I assume that should be quote_identifier(rid->partrel)?

= Other Notes =

GCC 11 complains about the following code in pgpa_collect_advice():

>        dsa_area   *area = pg_plan_advice_dsa_area();
>        dsa_pointer ca_pointer;
>
>        pgpa_make_collected_advice(userid, dbid, queryId, now,
>                                   query_string, advice_string, area,
>                                   &ca_pointer);
>        pgpa_store_shared_advice(ca_pointer);

It doesn't know that area is guaranteed to be non-NULL, so it can't
prove that ca_pointer is initialized.

(GCC also complains about unique_nonjoin_rtekind() not initializing
the rtekind, but I think that's because of a bug [1].)

--Jacob

[1] https://gcc.gnu.org/bugzilla/show_bug.cgi?id=107838





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-09 19:34  Robert Haas <[email protected]>
  parent: Greg Burd <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2025-12-09 19:34 UTC (permalink / raw)
  To: Greg Burd <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Mon, Dec 8, 2025 at 3:39 PM Greg Burd <[email protected]> wrote:
> Thanks for working on this!  I think the idea has merit, I hope it lands sometime soon.

Thanks. I think it needs a good deal more review first, but I
appreciate the support.

> > Attachments:
> > * v5-0001-Store-information-about-range-table-flattening-in.patch
>
> contrib/pg_overexplain/pg_overexplain.c
>
> +               /* Advance to next SubRTInfo, if it's time. */
> +               if (lc_subrtinfo != NULL)
> +               {
> +                       next_rtinfo = lfirst(lc_subrtinfo);
> +                       if (rti > next_rtinfo->rtoffset)
>
> Should the test be >= not >? Unless I am I reading this wrong, when rti == rtoffset, that's the first entry of the new subplan's range table. That would mean that the current logic skips displaying the subplan name for the first RTE of each subplan.

I don't think so. I think I actually had it that way at one point, and
I believe I found that it was wrong. RTIs are 1-based, so the smallest
per-subquery RTI is 1. rtoffset is the amount that must be added to
the per-subquery RTI to get a "flat" RTI that can be used to index
into the final range table. But if you find that theoretical argument
unconvincing, by all means please test it and see what happens!

> > This commit teaches pg_overexplain'e RANGE_TABLE option to make use
> Minor nit in the commit message, "pg_overexplain'e" should be "pg_overexplain's"

Thanks, fixed in my local branch.

> > * v5-0002-Store-information-about-elided-nodes-in-the-final.patch
>
> +/*
> + * Record some details about a node removed from the plan during setrefs
> + * procesing, for the benefit of code trying to reconstruct planner decisions
> + * from examination of the final plan tree.
> + */
>
> Nit, "procesing" should be "processing"

Thanks, fixed in my local branch.

> > * v5-0003-Store-information-about-Append-node-consolidation.patch
>
> src/backend/optimizer/path/allpaths.c
>
> /* Now consider each interesting sort ordering */
> foreach(lcp, all_child_pathkeys)
> {
>         List       *subpaths = NIL;
>         bool            subpaths_valid = true;
> +       List       *subpath_cars = NIL;
>         List       *startup_subpaths = NIL;
>         bool            startup_subpaths_valid = true;
> +       List       *startup_subpath_cars = NIL;
>         List       *partial_subpaths = NIL;
> +       List       *partial_subpath_cars = NIL;
>         List       *pa_partial_subpaths = NIL;
>         List       *pa_nonpartial_subpaths = NIL;
> +       List       *pa_subpath_cars = NIL;
>
> I find "cars" a bit cryptic (albeit clever), I think I've decoded it properly and it stands for "child_append_relid_sets", correct?  Could you add a comment or use a clearer name like subpath_child_relids or consolidated_relid_sets?

I certainly admit that this is a bit too clever. I am not entirely
sure how to make it less clever. There needs to be a
child-append-relid-sets list corresponding to every current and future
subpath list, and the names of some of those subpath lists are already
quite long, so whatever naming convention we choose for the "cars"
lists had better not add too much more length to the variable name. I
felt like someone looking at this might initially be confused by what
"cars" meant, but then I thought that they would probably look at how
the variable was used and see that it was for example being passed as
the second argument to get_singleton_append_subpath(), which is named
child_append_relid_sets, or being passed to create_append_path or
create_merge_append_path, which also use that naming. I figured that
this would clear up the confusion pretty quickly. I could certainly
add a comment above this block of variable assignments saying
something like "for each list of paths, we must also maintain a list
of child append relid sets, etc. etc." but I worried that this would
create as much confusion as it solved, i.e. somebody reading the code
would be going: why is this comment here? Is it trying to tell me that
there's something weirder going on than what is anyway obvious?

If I get more opinions that some clarification is needed here, I'm
happy to change it, especially if those opinions agree with each other
on exactly what to change, but I think for now I'll leave it as it is.

> +accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
> +                                                 List **child_append_relid_sets)
>  {
>         if (IsA(path, AppendPath))
>         {
> @@ -2219,6 +2256,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
>                 if (!apath->path.parallel_aware || apath->first_partial_path == 0)
>                 {
>                         *subpaths = list_concat(*subpaths, apath->subpaths);
> +                       *child_append_relid_sets =
> +                               lappend(*child_append_relid_sets, path->parent->relids);
>
> Is it possible that when pulling up multiple subpaths from an AppendPath, only ONE relid set is added to child_append_relid_sets, but MULTIPLE paths are added to subpaths?  If so, that would break the correspondence between the lists which would be bad, right?

That would indeed be bad, but I'm not clear on how you think it could
happen. Can you clarify?

> src/include/nodes/pathnodes.h
> + * Whenever accumulate_append_subpath() allows us to consolidate multiple
> + * levels of Append paths are consolidated down to one, we store the RTI
> + * sets for the omitted paths in child_append_relid_sets. This is not necessary
> + * for planning or execution; we do it for the benefit of code that wants
> + * to inspect the final plan and understand how it came to be.
>
> Minor: "paths are consolidated" is redundant, should be "paths consolidated" or "allows us to consolidate".

Thanks, fixed in my local branch.

> > * v5-0004-Allow-for-plugin-control-over-path-generation-str.patch
>
> src/backend/optimizer/path/costsize.c
> +       else
> +               enable_mask |= PGS_CONSIDER_NONPARTIAL;
>
> -       path->disabled_nodes = enable_seqscan ? 0 : 1;
> +       path->disabled_nodes =
> +               (baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
>
> When parallel_workers > 0 the path is partial and doesn't need PGS_CONSIDER_NONPARTIAL. But if parallel_workers == 0, it's non-partial and DOES need it, right?  Would this mean that non-partial paths can be disabled even when the scan type itself (e.g., PGS_SEQSCAN) is enabled?  Intentional?

See this comment:

 * Finally, unsetting PGS_CONSIDER_NONPARTIAL disables all non-partial paths
 * except those that use Gather or Gather Merge. In most other cases, a
 * plugin can nudge the planner toward a particular strategy by disabling
 * all of the others, but that doesn't work here: unsetting PGS_SEQSCAN,
 * for instance, would disable both partial and non-partial sequential scans.

> It seems this is still WIP with a solid start, I'm not going to dig too much into it. :)
>
> Keep it up, best.

Thanks for the review so far!


--
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-09 19:45  Robert Haas <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-12-09 19:45 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Dec 8, 2025 at 8:19 PM Jacob Champion
<[email protected]> wrote:
> I really like this idea! Telling the planner, "if you need to make a
> decision for [this thing], choose [this way]," seems to be a really
> nice way of sidestepping many of the concerns with "user control".
>
> I've started an attempt to throw a fuzzer at this, because I'm pretty
> useless when it comes to planner/optimizer review. I don't really know
> what the overall fuzzing strategy is going to be, given the multiple
> complicated inputs that have to be constructed and somehow correlated
> with each other, but I'll try to start small and expand:
>
> a) fuzz the parser first, because it's easy and we can get interesting inputs
> b) fuzz the AST utilities, seeded with "successful" corpus members from a)
> c) stare really hard at the corpus of b) and figure out how to
> usefully mutate a PlannedStmt with it
> d) use c) to fuzz pgpa_plan_walker, then pgpa_output_advice, then...?

Cool. I'm bad at fuzzing, but I think fuzzing by someone who is good
at it is very promising for this kind of patch.

> I'm in the middle of an implementation of b) now, and it noticed the
> following code (which probably bodes well for the fuzzer itself!):
>
> >        if (rid->partnsp == NULL)
> >            result = psprintf("%s/%s", result,
> >                              quote_identifier(rid->partnsp));
>
> I assume that should be quote_identifier(rid->partrel)?

Yes, thanks. Fixed locally. By the way, if your fuzzer can also
produces some things to add contrib/pg_plan_advice/sql for cases like
this, that would be quite helpful. Ideally I would have caught this
with a manually-written test case, but obviously that didn't happen.

> = Other Notes =
>
> GCC 11 complains about the following code in pgpa_collect_advice():
>
> >        dsa_area   *area = pg_plan_advice_dsa_area();
> >        dsa_pointer ca_pointer;
> >
> >        pgpa_make_collected_advice(userid, dbid, queryId, now,
> >                                   query_string, advice_string, area,
> >                                   &ca_pointer);
> >        pgpa_store_shared_advice(ca_pointer);
>
> It doesn't know that area is guaranteed to be non-NULL, so it can't
> prove that ca_pointer is initialized.

I don't know what to do about that. I can understand why it might be
unable to prove that, but I don't see an obvious way to change the
code that would make life easier. I could add Assert(area != NULL)
before the call to pgpa_make_collected_advice() if that helps.

> (GCC also complains about unique_nonjoin_rtekind() not initializing
> the rtekind, but I think that's because of a bug [1].)

This one could be fixed with a dummy initialization, if needed.

--
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-10 11:20  Amit Langote <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 1 reply; 133+ messages in thread

From: Amit Langote @ 2025-12-10 11:20 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

Hi Robert,

On Thu, Oct 30, 2025 at 11:00 PM Robert Haas <[email protected]> wrote:
> As I have mentioned on previous threads, for the past while I have
> been working on planner extensibility. I've posted some extensibility
> patches previously, and got a few of them committed in
> Sepember/October with Tom's help, but I think the time has come a
> patch which actually makes use of that infrastructure as well as some
> further infrastructure that I'm also including in this posting.[1] The
> final patch in this series adds a new contrib module called
> pg_plan_advice. Very briefly, what pg_plan_advice knows how to do is
> process a plan and emits a (potentially long) long text string in a
> special-purpose mini-language that describes a bunch of key planning
> decisions, such as the join order, selected join methods, types of
> scans used to access individual tables, and where and how
> partitionwise join and parallelism were used. You can then set
> pg_plan_advice.advice to that string to get a future attempt to plan
> the same query to reproduce those decisions, or (maybe a better idea)
> you can trim that string down to constrain some decisions (e.g. the
> join order) but not others (e.g. the join methods), or (if you want to
> make your life more exciting) you can edit that advice string and
> thereby attempt to coerce the planner into planning the query the way
> you think best. There is a README that explains the design philosophy
> and thinking in a lot more detail, which is a good place to start if
> you're curious, and I implore you to read it if you're interested, and
> *especially* if you're thinking of flaming me.

Thanks for posting this.  Looks very interesting to me.

These are just high-level comments after browsing the patches and
reading some bits like pgpa_identifier to get myself familiarized with
the project.  I like that the key concept here is plan stability
rather than plan control, because that framing makes it easier to
treat this as infrastructure instead of policy.

> I want to mention that, beyond the fact that I'm sure some people will
> want to use something like this (with more feature and a lot fewer
> bugs) in production, it seems to be super-useful for testing. We have
> a lot of regression test cases that try to coerce the planner to do a
> particular thing by manipulating enable_* GUCs, and I've spent a lot
> of time trying to do similar things by hand, either for regression
> test coverage or just private testing. This facility, even with all of
> the bugs and limitations that it currently has, is exponentially more
> powerful than frobbing enable_* GUCs. Once you get the hang of the
> advice mini-language, you can very quickly experiment with all sorts
> of plan shapes in ways that are currently very hard to do, and thereby
> find out how expensive the planner thinks those things are and which
> ones it thinks are even legal. So I see this as not only something
> that people might find useful for in production deployments, but also
> something that can potentially be really useful to advance PostgreSQL
> development.

+1, the testing benefits make this worthwhile.

> Which brings me to the question of where this code ought to go if it
> goes anywhere at all. I decided to propose pg_plan_advice as a contrib
> module rather than a part of core because I had to make a WHOLE lot of
> opinionated design decisions just to get to the point of having
> something that I could post and hopefully get feedback on. I figured
> that all of those opinionated decisions would be a bit less
> unpalatable if they were mostly encapsulated in a contrib module, with
> the potential for some future patch author to write a different
> contrib module that adopted different solutions to all of those
> problems. But what I've also come to realize is that there's so much
> infrastructure here that leaving the next person to reinvent it may
> not be all that appealing. Query jumbling is a previous case where we
> initially thought that different people might want to do different
> things, but eventually realized that most people really just wanted
> some solution that they didn't have to think too hard about. Likewise,
> in this patch, the relation identifier system described in the README
> is the only thing of its kind, to my knowledge, and any system that
> wants to accomplish something similar to what pg_plan_advice does
> would need a system like that. pg_hint_plan doesn't have something
> like that, because pg_hint_plan is just trying to do hints. This is
> trying to do round-trip-safe plan stability, where the system will
> tell you how to refer unambiguously to a certain part of the query in
> a way that will work correctly on every single query regardless of how
> it's structured or how many times it refers to the same tables or to
> different tables using the same aliases. If we say that we're never
> going to put any of that infrastructure in core, then anyone who wants
> to write a module to control the planner is going to need to start by
> either (a) reinventing something similar, (b) cloning all the relevant
> code, or (c) just giving up on the idea of unambiguous references to
> parts of a query. None of those seem like great options, so now I'm
> less sure whether contrib is actually the right place for this code,
> but that's where I have put it for now. Feedback welcome, on this and
> everything else.

On the relation identifier system: IMHO this part doesn't seem as
opinionated as the advice mini-language. The requirements pretty much
dictate the design -- you need alias names and occurrence counters to
handle self-joins, partition fields for partitioned tables, and a
string representation to survive dump/restore. There doesn't seem to
be much flexibility in that.

Given that, it seems more practical to put this in core from the
start. Extensions that might want to build plan-advice-like
functionality shouldn’t have to clone this logic and wait another
release for something that’s already well-defined and deterministic.
The mini-language is opinionated and belongs in contrib, but the
identifier infrastructure just solves a fundamental problem cleanly.

On the infrastructure patches (0001-0005): these look sensible. The
range table flattening info, elided node tracking, and append node
consolidation preserve information that's currently lost -- there's
some additional overhead to track this, but it's fixed per-relation
per-subquery, which seems reasonable.  The path generation hooks
(0005) are a clear improvement: moving from global enable_* GUCs to
per-RelOptInfo pgs_mask gives extensions the granularity they need for
relation-specific and join-specific decisions. Yes, you need C code to
use them, but you'd need to write C code to do something of value in
this area anyway, and the hooks give you control that GUCs can't
provide.

Overall, I'm supportive of getting these committed once they're ready.
contrib/pg_plan_advice is a compelling proof-of-concept for why these
hooks are needed.

I'll try to post more specific comments once I've read this some more.

--
Thanks, Amit Langote





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-10 11:43  Jakub Wartak <[email protected]>
  parent: Robert Haas <[email protected]>
  3 siblings, 1 reply; 133+ messages in thread

From: Jakub Wartak @ 2025-12-10 11:43 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Dec 5, 2025 at 8:57 PM Robert Haas <[email protected]> wrote:
[..]
> 014f9a831a320666bf2195949f41710f970c54ad removes the need for what was
> previously 0004, so here is a new patch series with that dropped, to
> avoid confusing cfbot or human reviewers.

Quick-question regarding cross-interactions of the extensions: would
it be possible for auto_explain to have something like
auto_explain.log_custom_options='PLAN_ADVICES' so that it could be
dumping the advice of the queries involved . I can see there is
ApplyExtensionExplainOption() and that would have to probably be used
by auto_explain(?) Or is there any other better way or perhaps it
somehow is against some design or it's just outside of initial scope?
This would solve two problems:
a) sometimes explaining manually (psql) is simply not realistic as it
is being run by app only
b) auto_explain could log nested queries and could print plan advices
along the way, which can be very painful process otherwise
(reverse-engineering how the optimizer would name things  in more
complex queries run from inside PLPGSQL functions)

BTW, some feedback: the plan advices (plan fixing) seems to work fine
for nested queries inside PLPGSQL, and also I've discovered (?) that
one can do even today with patchset the following:
   alter function blah(bigint) set pg_plan_advice.advice =
'NESTED_LOOP_MATERIALIZE(b)';
which seems to be pretty cool, because it allows more targeted fixes
without even having capability of fixing plans for specific query_id
(as discussed earlier).

For the generation part, the only remaining thing is how it integrates
with partitions (especially the ones being dynamically created/dropped
over time). Right now one needs to keep the advice(s) in sync after
altering the partitions, but it could be expected that some form of
regexp/partition-templating would be built into pg_plan_advices
instead. Anyway, I think this one should go into documentation just as
known-limitations for now.

While scratching my head on how to prove that this is not crashing
I've also checked below ones (TLDR all ok):
1. PG_TEST_INITDB_EXTRA_OPTS="-c
shared_preload_libraries='pg_plan_advice'"  meson test  # It was clean
2. PG_TEST_INITDB_EXTRA_OPTS="-c
shared_preload_libraries='pg_plan_advice'" PGOPTIONS="-c
pg_plan_advice.advice=NESTED_LOOP_MATERIALIZE(certainlynotused)" meson
test # This had several failures, but all is OK: it's just some of
them had to additional (expected) text inside regression.diffs:
NESTED_LOOP_MATERIALIZE(certainlynotused) /* not matched */
3. PG_TEST_INITDB_EXTRA_OPTS="-c
shared_preload_libraries='pg_plan_advice' -c
pg_plan_advice.shared_collection_limit=42"  meson test # It was clean
too

-J.





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-10 13:33  Robert Haas <[email protected]>
  parent: Jakub Wartak <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2025-12-10 13:33 UTC (permalink / raw)
  To: Jakub Wartak <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Dec 10, 2025 at 6:43 AM Jakub Wartak
<[email protected]> wrote:
> Quick-question regarding cross-interactions of the extensions: would
> it be possible for auto_explain to have something like
> auto_explain.log_custom_options='PLAN_ADVICES' so that it could be
> dumping the advice of the queries involved

Yes, I had the same idea. I think the tricky part here is that an
option can have an argument. Most options will probably have Boolean
arguments, but there are existing in-core counterexamples, such as
FORMAT. We should try to figure out the GUC in such a way that it can
be used either to set a Boolean option by just specifying it, or that
it can be used to set an option to a value by writing both. Maybe it's
fine if the GUC value is just a comma-separated list of entries, and
each entry can either be an option name or an option name followed by
a space followed by an option value, i.e. if FORMAT were custom, then
you could write auto_explain.log_custom_options='format xml,
plan_advice' or auto_explain.log_custom_options='plan_advice true,
range_table false' and have sensible things happen. In fact, very
possibly the GUC should just accept any options whether in-core or
out-of-core and not distinguish, so it would be more like
auto_explain.log_options.

> BTW, some feedback: the plan advices (plan fixing) seems to work fine
> for nested queries inside PLPGSQL, and also I've discovered (?) that
> one can do even today with patchset the following:
>    alter function blah(bigint) set pg_plan_advice.advice =
> 'NESTED_LOOP_MATERIALIZE(b)';
> which seems to be pretty cool, because it allows more targeted fixes
> without even having capability of fixing plans for specific query_id
> (as discussed earlier).

Yes, this is a big advantage of reusing the GUC machinery for this
purpose (but see the thread on "[PATCH] Allow complex data for GUC
extra").

> For the generation part, the only remaining thing is how it integrates
> with partitions (especially the ones being dynamically created/dropped
> over time). Right now one needs to keep the advice(s) in sync after
> altering the partitions, but it could be expected that some form of
> regexp/partition-templating would be built into pg_plan_advices
> instead. Anyway, I think this one should go into documentation just as
> known-limitations for now.

Right. I don't think trying to address this at this stage makes sense.
To maintain my sanity, I want to focus for now only on things that
round-trip: that is, we can generate it, and then we can accept that
same stuff. If we're using a parallel plan for every partition e.g.
they are all sequential scans or all index scans, we could generate
SEQ_SCAN(foo/*) or similar and then we could accept that. But figuring
that out would take a bunch of additional infrastructure that I don't
have the time or energy to create right this minute, and I don't see
it as anywhere close to essential for v1. Some other problems here:

1. What happens when a small number of partitions are different? The
code puts quite a bit of energy into detecting conflicting advice, and
honestly probably should put even more, and you might say, well, if
there's just one partition that used an index scan, then I still want
the advice to read SEQ_SCAN(foo/*) INDEX_SCAN(foo/foo23 foo23_a_idx)
and not signal a conflict, but that's slightly unprincipled.

2. INDEX_SCAN() specifications and similar will tend not to be
different for every partition because the index names will be
different for every partition. You might want something that says "for
each partition of foo, use the index on that partition that is a child
of this index on the parent".

Long run, there's a lot of things that can be added to this to make it
more concise (and more expressive, too). Another similar idea is to
have something like NO_GATHER_UNLESS_I_SAID_SO() so that a
non-parallel query doesn't have to do NO_GATHER(every single relation
including all the partitions). I'm pretty sure this is a valuable
idea, but, again, it's not essential for v1.

> While scratching my head on how to prove that this is not crashing
> I've also checked below ones (TLDR all ok):
> 1. PG_TEST_INITDB_EXTRA_OPTS="-c
> shared_preload_libraries='pg_plan_advice'"  meson test  # It was clean
> 2. PG_TEST_INITDB_EXTRA_OPTS="-c
> shared_preload_libraries='pg_plan_advice'" PGOPTIONS="-c
> pg_plan_advice.advice=NESTED_LOOP_MATERIALIZE(certainlynotused)" meson
> test # This had several failures, but all is OK: it's just some of
> them had to additional (expected) text inside regression.diffs:
> NESTED_LOOP_MATERIALIZE(certainlynotused) /* not matched */
> 3. PG_TEST_INITDB_EXTRA_OPTS="-c
> shared_preload_libraries='pg_plan_advice' -c
> pg_plan_advice.shared_collection_limit=42"  meson test # It was clean
> too

You can set pg_plan_advice.always_explain_supplied_advice=false to
clean up some of the noise here. This kind of testing is why I
invented that option. I think that in production, we REALLY REALLY
want any supplied advice to show up in the EXPLAIN plan even if the
user did not specify the PLAN_ADVICE option to EXPLAIN. Otherwise,
understanding what is going on with an EXPLAIN plan that a
hypothetical customer sends to a hypothetical PostgreSQL expert who
has to support said hypothetical customer will be a miserable
experience. But for testing purposes, it's nice to be able to shut it
off so you don't get random regression diffs.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-10 14:54  Robert Haas <[email protected]>
  parent: Amit Langote <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-12-10 14:54 UTC (permalink / raw)
  To: Amit Langote <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Wed, Dec 10, 2025 at 6:20 AM Amit Langote <[email protected]> wrote:
> These are just high-level comments after browsing the patches and
> reading some bits like pgpa_identifier to get myself familiarized with
> the project.  I like that the key concept here is plan stability
> rather than plan control, because that framing makes it easier to
> treat this as infrastructure instead of policy.

Thanks, I agree. I'm sure people will use this for plan control, but
if you start with that, then it's really unclear what things you
should allow to be controlled and what things not. Defining the focus
as plan stability makes round-trip safety a priority and the scope of
what you can request is what the planner could have generated had the
costing come out just so. There's still some definitional questions at
the margin, but IMHO it's much less fuzzy.

> On the relation identifier system: IMHO this part doesn't seem as
> opinionated as the advice mini-language. The requirements pretty much
> dictate the design -- you need alias names and occurrence counters to
> handle self-joins, partition fields for partitioned tables, and a
> string representation to survive dump/restore. There doesn't seem to
> be much flexibility in that.

Right. There's some flexibility. For instance, you could handle
partitions using occurrence numbers, which would actually save a bunch
of code, but that seems obviously worse in terms of user experience.
Also, you could if you wanted key it off of the name of the table
rather than the relation alias used for the table. I think that's also
worse but possibly it's debatable. You could change the order of the
pieces in the representation; e.g. maybe plan_name should come first
rather than last; or you could change the separator characters. But,
honestly, none of that strikes me as sufficient grounds to want
multiple implementations. If the choices I've made don't seem good to
other people, then we should just change them and hopefully find
something everybody can live with. It's a bit like the way that
extension SQL scripts use "--" as a separator: maybe not everybody
agrees that this is the absolutely most elegant choice, but nobody's
proposing a a second version of the extension mechanism just to do
something different.

> Given that, it seems more practical to put this in core from the
> start. Extensions that might want to build plan-advice-like
> functionality shouldn’t have to clone this logic and wait another
> release for something that’s already well-defined and deterministic.
> The mini-language is opinionated and belongs in contrib, but the
> identifier infrastructure just solves a fundamental problem cleanly.

It's not quite as easy to make a sharp distinction between these
things as someone might hope. Note that the lexer and parser handle
the whole mini-language, which includes parsing the relation
identifiers. That doesn't of course mean that the code to *generate*
relation identifiers couldn't be in core, and I actually had it that
way at one point, but it's not very much code and I wasn't too
impressed with how that turned out. It seemed to couple the core code
to the extension more tightly than necessary for not much real
benefit.

But that's not to say I disagree with you categorically. Suppose we
decided (and I'm not saying we should) to start showing relation
identifiers in EXPLAIN output instead of identifying things in EXPLAIN
output as we do today. Maybe we even decide to show elided subqueries
and similar as first-class parts of the EXPLAIN output, also using
relation identifier syntax. That would be a pretty significant change,
and would destabilize a WHOLE LOT of regression test outputs, but then
relation identifiers become a first-class PostgreSQL concept that
everyone who looks at EXPLAIN output will encounter and, probably,
come to understand. Then obviously the relation identifier generation
code needs to be in core, and that makes total sense because we're
actually using it for something, and arguably we've made life easier
for everyone who wants to use pg_plan_advice in the future because
they're already familiar with the identifier convention. The downside
is everyone has to get used to the new EXPLAIN output even if they
don't care about pg_plan_advice or hate it with a fiery passion.

So my point here is that there are things we can decide to do to make
some or all of this "core," but IMHO it's not just as simple as saying
"this is in, that's out". It's more about deciding what the end state
ought to look like, and how integrated this stuff ought to be into the
fabric of PostgreSQL. I started with the minimal level of integration:
little pieces of core infrastructure, all used by a giant extension.
Now we need to either decide that's where we want to settle, or decide
to push to some greater or lesser degree toward more integration.

> On the infrastructure patches (0001-0005): these look sensible. The
> range table flattening info, elided node tracking, and append node
> consolidation preserve information that's currently lost -- there's
> some additional overhead to track this, but it's fixed per-relation
> per-subquery, which seems reasonable.  The path generation hooks
> (0005) are a clear improvement: moving from global enable_* GUCs to
> per-RelOptInfo pgs_mask gives extensions the granularity they need for
> relation-specific and join-specific decisions. Yes, you need C code to
> use them, but you'd need to write C code to do something of value in
> this area anyway, and the hooks give you control that GUCs can't
> provide.
>
> Overall, I'm supportive of getting these committed once they're ready.
> contrib/pg_plan_advice is a compelling proof-of-concept for why these
> hooks are needed.

Great. I don't think there's anything terribly controversial in
0001-0004. I think the comments and so on might need improving and
there could be little mini-bugs or whatever, but basically I think
they work and I don't anticipate any major problems. However, I'd want
at least one other person to do a detailed review before committing
anything. 0005 might be a little more controversial. There's some
design choices to dislike (though I believe I've made them for good
reason) and there's a question of whether it's as complete as we want.
It might be fine to commit it the way it is and just adjust it later
if we find that something ought to be different, but it's also
possible that we should think harder about some of the choices or hold
off for a bit while other parts of this effort move forward. I'm happy
to hear opinions on the best strategy here.

> I'll try to post more specific comments once I've read this some more.

Thanks for the review so far, and that sounds great!

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-10 21:09  Corey Huinker <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Corey Huinker @ 2025-12-10 21:09 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Amit Langote <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Dec 10, 2025 at 9:54 AM Robert Haas <[email protected]> wrote:

> On Wed, Dec 10, 2025 at 6:20 AM Amit Langote <[email protected]>
> wrote:
> > These are just high-level comments after browsing the patches and
> > reading some bits like pgpa_identifier to get myself familiarized with
> > the project.  I like that the key concept here is plan stability
> > rather than plan control, because that framing makes it easier to
> > treat this as infrastructure instead of policy.
>
> Thanks, I agree. I'm sure people will use this for plan control, but
> if you start with that, then it's really unclear what things you
> should allow to be controlled and what things not. Defining the focus
> as plan stability makes round-trip safety a priority and the scope of
> what you can request is what the planner could have generated had the
> costing come out just so. There's still some definitional questions at
> the margin, but IMHO it's much less fuzzy.
>

I couldn't have said this any better than Amit did. In my experience, lack
of a plan stability feature is far and away the most cited reason for not
porting to PostgreSQL. They want query plan stability first and foremost.
The amount of plan tweaking they do is actually pretty minimal, once they
get good-enough performance during user acceptance they want to encase
those query plans in amber because that's what the customer signed-off on.
After that, they're happy to scan the performance trendlines, and only make
tweaks when it's worth a change request.

But that's not to say I disagree with you categorically. Suppose we
> decided (and I'm not saying we should) to start showing relation
> identifiers in EXPLAIN output instead of identifying things in EXPLAIN
> output as we do today. Maybe we even decide to show elided subqueries
> and similar as first-class parts of the EXPLAIN output, also using
> relation identifier syntax. That would be a pretty significant change,
> and would destabilize a WHOLE LOT of regression test outputs, but then
> relation identifiers become a first-class PostgreSQL concept that
> everyone who looks at EXPLAIN output will encounter and, probably,
> come to understand.


I think the change would be worth the destabilization, because it makes it
so much easier to talk about complex query plans. Additionally, it would
make it reasonable to programmatically extract portions of a plan, allowing
for much more fine-grained regression tests regarding plans.

Showing the elided subqueries would be a huge benefit, outlining the
benefits that the planner is giving you "for free".


> > On the infrastructure patches (0001-0005): these look sensible. The
> > range table flattening info, elided node tracking, and append node
>

One thing I am curious about is that by tracking the elided nodes, would it
make more sense in the long run to have the initial post-naming plan tree
be immutable, and generate a separate copy minus the elided parts?


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-10 21:29  Robert Haas <[email protected]>
  parent: Corey Huinker <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2025-12-10 21:29 UTC (permalink / raw)
  To: Corey Huinker <[email protected]>; +Cc: Amit Langote <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Dec 10, 2025 at 4:09 PM Corey Huinker <[email protected]> wrote:
> I think the change would be worth the destabilization, because it makes it so much easier to talk about complex query plans. Additionally, it would make it reasonable to programmatically extract portions of a plan, allowing for much more fine-grained regression tests regarding plans.

I'll wait for more votes before thinking about doing anything about
this, because I have my doubts about whether the consensus will
actually go in favor of such a large change. Or maybe someone else
would like to try mocking it up (even if somewhat imperfectly) so we
can all see just how large an impact it makes.

>> > On the infrastructure patches (0001-0005): these look sensible. The
>> > range table flattening info, elided node tracking, and append node
>
> One thing I am curious about is that by tracking the elided nodes, would it make more sense in the long run to have the initial post-naming plan tree be immutable, and generate a separate copy minus the elided parts?

Probably not. Having two entire copies of the plan tree would be
pretty expensive. I think that we've bet on the right idea, namely,
that the primary consumer of plan trees should be the executor, and
the primary goal should be to create plan trees that make the executor
run fast. I believe the right approach is basically what we do today:
you're allowed to put things into the plan that aren't technically
necessary for execution, if they're useful for instrumentation and
observability purposes and they don't add an unreasonable amount of
overhead. These patches basically just extend that existing principle
to a few new things.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-11 15:09  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  3 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2025-12-11 15:09 UTC (permalink / raw)
  To: PostgreSQL Hackers <[email protected]>

On Fri, Dec 5, 2025 at 2:57 PM Robert Haas <[email protected]> wrote:
> 014f9a831a320666bf2195949f41710f970c54ad removes the need for what was
> previously 0004, so here is a new patch series with that dropped, to
> avoid confusing cfbot or human reviewers.

Here's v6, with minor improvements over v5.

0001: Unchanged.

0002, 0003: Unchanged except for typo fixes pointed out by reviewers.

0004: I've improved the hook placement, which was previously such as
to make correct unique-semijoin handling impossible, and I improved
the associated comment about how to use the hook, based on experience
trying to actually do so.

0005: Fixed a small bug related to unique-semijoin handling (other
problems remain). Tidied things up to avoid producing non-actionable
NO_GATHER() advice in a number of cases, per some off-list feedback
from Ajaykumar Pal.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v6-0002-Store-information-about-elided-nodes-in-the-final.patch (9.3K, 2-v6-0002-Store-information-about-elided-nodes-in-the-final.patch)
  download | inline diff:
From 8098a0f1d838af203ab9c8fdb63a816a6387edb2 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:42 -0400
Subject: [PATCH v6 2/5] Store information about elided nodes in the final
 plan.

An extension (or core code) might want to reconstruct the planner's
choice of join order from the final plan. To do so, it must be possible
to find all of the RTIs that were part of the join problem in that plan.
The previous commit, together with the earlier work in
8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0, is enough to let us match up
RTIs we see in the final plan with RTIs that we see during the planning
cycle, but we still have a problem if the planner decides to drop some
RTIs out of the final plan altogether.

To fix that, when setrefs.c removes a SubqueryScan, single-child Append,
or single-child MergeAppend from the final Plan tree, record the type of
the removed node and the RTIs that the removed node would have scanned
in the final plan tree. It would be natural to record this information
on the child of the removed plan node, but that would require adding
an additional pointer field to type Plan, which seems undesirable.
So, instead, store the information in a separate list that the
executor need never consult, and use the plan_node_id to identify
the plan node with which the removed node is logically associated.

Also, update pg_overexplain to display these details.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 39 ++++++++++++++
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/plan/setrefs.c          | 52 ++++++++++++++++++-
 src/include/nodes/pathnodes.h                 |  3 ++
 src/include/nodes/plannodes.h                 | 17 ++++++
 src/tools/pgindent/typedefs.list              |  1 +
 7 files changed, 114 insertions(+), 3 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 55d34666d87..ca9a23ea61f 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -452,6 +452,8 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
  Seq Scan on daucus vegetables
    Filter: (genus = 'daucus'::text)
    Scan RTI: 2
+   Elided Node Type: Append
+   Elided Node RTIs: 1
  RTI 1 (relation, inherited, in-from-clause):
    Eref: vegetables (id, name, genus)
    Relation: vegetables
@@ -465,7 +467,7 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 2
-(16 rows)
+(18 rows)
 
 -- Also test a case that involves a write.
 EXPLAIN (RANGE_TABLE, COSTS OFF)
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index 1c4c796adb2..e54f8cfc332 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -191,6 +191,8 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 	 */
 	if (options->range_table)
 	{
+		bool		opened_elided_nodes = false;
+
 		switch (nodeTag(plan))
 		{
 			case T_SeqScan:
@@ -251,6 +253,43 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 			default:
 				break;
 		}
+
+		foreach_node(ElidedNode, n, es->pstmt->elidedNodes)
+		{
+			char	   *elidednodetag;
+
+			if (n->plan_node_id != plan->plan_node_id)
+				continue;
+
+			if (!opened_elided_nodes)
+			{
+				ExplainOpenGroup("Elided Nodes", "Elided Nodes", false, es);
+				opened_elided_nodes = true;
+			}
+
+			switch (n->elided_type)
+			{
+				case T_Append:
+					elidednodetag = "Append";
+					break;
+				case T_MergeAppend:
+					elidednodetag = "MergeAppend";
+					break;
+				case T_SubqueryScan:
+					elidednodetag = "SubqueryScan";
+					break;
+				default:
+					elidednodetag = psprintf("%d", n->elided_type);
+					break;
+			}
+
+			ExplainOpenGroup("Elided Node", NULL, true, es);
+			ExplainPropertyText("Elided Node Type", elidednodetag, es);
+			overexplain_bitmapset("Elided Node RTIs", n->relids, es);
+			ExplainCloseGroup("Elided Node", NULL, true, es);
+		}
+		if (opened_elided_nodes)
+			ExplainCloseGroup("Elided Nodes", "Elided Nodes", false, es);
 	}
 }
 
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 31dcbdf3422..df9d03d5492 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -618,6 +618,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->paramExecTypes = glob->paramExecTypes;
 	/* utilityStmt should be null, but we might as well copy it */
 	result->utilityStmt = parse->utilityStmt;
+	result->elidedNodes = glob->elidedNodes;
 	result->stmt_location = parse->stmt_location;
 	result->stmt_len = parse->stmt_len;
 
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index edcd4aaa53e..407ec39b63b 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -211,6 +211,9 @@ static List *set_windowagg_runcondition_references(PlannerInfo *root,
 												   List *runcondition,
 												   Plan *plan);
 
+static void record_elided_node(PlannerGlobal *glob, int plan_node_id,
+							   NodeTag elided_type, Bitmapset *relids);
+
 
 /*****************************************************************************
  *
@@ -1460,10 +1463,17 @@ set_subqueryscan_references(PlannerInfo *root,
 
 	if (trivial_subqueryscan(plan))
 	{
+		Index		scanrelid;
+
 		/*
 		 * We can omit the SubqueryScan node and just pull up the subplan.
 		 */
 		result = clean_up_removed_plan_level((Plan *) plan, plan->subplan);
+
+		/* Remember that we removed a SubqueryScan */
+		scanrelid = plan->scan.scanrelid + rtoffset;
+		record_elided_node(root->glob, plan->subplan->plan_node_id,
+						   T_SubqueryScan, bms_make_singleton(scanrelid));
 	}
 	else
 	{
@@ -1891,7 +1901,17 @@ set_append_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(aplan->appendplans);
 
 		if (p->parallel_aware == aplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) aplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) aplan, p);
+
+			/* Remember that we removed an Append */
+			record_elided_node(root->glob, p->plan_node_id, T_Append,
+							   offset_relid_set(aplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -1959,7 +1979,17 @@ set_mergeappend_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(mplan->mergeplans);
 
 		if (p->parallel_aware == mplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) mplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) mplan, p);
+
+			/* Remember that we removed a MergeAppend */
+			record_elided_node(root->glob, p->plan_node_id, T_MergeAppend,
+							   offset_relid_set(mplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -3774,3 +3804,21 @@ extract_query_dependencies_walker(Node *node, PlannerInfo *context)
 	return expression_tree_walker(node, extract_query_dependencies_walker,
 								  context);
 }
+
+/*
+ * Record some details about a node removed from the plan during setrefs
+ * processing, for the benefit of code trying to reconstruct planner decisions
+ * from examination of the final plan tree.
+ */
+static void
+record_elided_node(PlannerGlobal *glob, int plan_node_id,
+				   NodeTag elided_type, Bitmapset *relids)
+{
+	ElidedNode *n = makeNode(ElidedNode);
+
+	n->plan_node_id = plan_node_id;
+	n->elided_type = elided_type;
+	n->relids = relids;
+
+	glob->elidedNodes = lappend(glob->elidedNodes, n);
+}
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 3782bc64075..42c146d802a 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -159,6 +159,9 @@ typedef struct PlannerGlobal
 	/* type OIDs for PARAM_EXEC Params */
 	List	   *paramExecTypes;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/* highest PlaceHolderVar ID assigned */
 	Index		lastPHId;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 1526dd2ec6b..5d0520d5e58 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -152,6 +152,9 @@ typedef struct PlannedStmt
 	/* non-null if this is utility stmt */
 	Node	   *utilityStmt;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/*
 	 * DefElem objects added by extensions, e.g. using planner_shutdown_hook
 	 *
@@ -1838,4 +1841,18 @@ typedef struct SubPlanRTInfo
 	bool		dummy;
 } SubPlanRTInfo;
 
+/*
+ * ElidedNode
+ *
+ * Information about nodes elided from the final plan tree: trivial subquery
+ * scans, and single-child Append and MergeAppend nodes.
+ */
+typedef struct ElidedNode
+{
+	NodeTag		type;
+	int			plan_node_id;
+	NodeTag		elided_type;
+	Bitmapset  *relids;
+} ElidedNode;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 025bba87834..8b300ae8233 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -701,6 +701,7 @@ EachState
 Edge
 EditableObjectType
 ElementsState
+ElidedNode
 EnableTimeoutParams
 EndDataPtrType
 EndDirectModify_function
-- 
2.51.0



  [application/octet-stream] v6-0004-Allow-for-plugin-control-over-path-generation-str.patch (55.7K, 3-v6-0004-Allow-for-plugin-control-over-path-generation-str.patch)
  download | inline diff:
From d45cb49d2f3c7d538ad947aea120e75871942dac Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 24 Oct 2025 15:11:47 -0400
Subject: [PATCH v6 4/5] Allow for plugin control over path generation
 strategies.

Each RelOptInfo now has a pgs_mask member which is a mask of acceptable
strategies. For most rels, this is populated from PlannerGlobal's
default_pgs_mask, which is computed from the values of the enable_*
GUCs at the start of planning.

For baserels, get_relation_info_hook can be used to adjust pgs_mask for
each new RelOptInfo, at least for rels of type RTE_RELATION. Adjusting
pgs_mask is less useful for other types of rels, but if it proves to
be necessary, we can revisit the way this hook works or add a new one.

For joinrels, two new hooks are added. joinrel_setup_hook is called each
time a joinrel is created, and one thing that can be done from that hook
is to manipulate pgs_mask for the new joinrel. join_path_setup_hook is
called each time we're about to add paths to a joinrel by considering
some particular combination of an outer rel, an inner rel, and a join
type. It can modify the pgs_mask propagated into JoinPathExtraData to
restrict strategy choice for that paricular combination of rels.

To make joinrel_setup_hook work as intended, the existing calls to
build_joinrel_partition_info are moved later in the calling functions;
this is because that function checks whether the rel's pgs_mask includes
PGS_CONSIDER_PARTITIONWISE, so we want it to only be called after
plugins have had a chance to alter pgs_mask.

Upper rels currently inherit pgs_mask from the input relation. It's
unclear that this is the most useful behavior, but at the moment there
are no hooks to allow the mask to be set in any other way.
---
 src/backend/optimizer/path/allpaths.c   |   2 +-
 src/backend/optimizer/path/costsize.c   | 222 ++++++++++++++++++------
 src/backend/optimizer/path/indxpath.c   |   4 +-
 src/backend/optimizer/path/joinpath.c   |  89 +++++++---
 src/backend/optimizer/path/tidpath.c    |   7 +-
 src/backend/optimizer/plan/createplan.c |   4 +-
 src/backend/optimizer/plan/planner.c    |  54 ++++++
 src/backend/optimizer/util/pathnode.c   |  19 +-
 src/backend/optimizer/util/plancat.c    |   3 +
 src/backend/optimizer/util/relnode.c    |  43 ++++-
 src/include/nodes/pathnodes.h           |  82 ++++++++-
 src/include/optimizer/cost.h            |   4 +-
 src/include/optimizer/pathnode.h        |  11 +-
 src/include/optimizer/paths.h           |   9 +-
 14 files changed, 455 insertions(+), 98 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 928b8d84ad8..8e9dde3d195 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -954,7 +954,7 @@ set_tablesample_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *
 		 bms_membership(root->all_query_rels) != BMS_SINGLETON) &&
 		!(GetTsmRoutine(rte->tablesample->tsmhandler)->repeatable_across_scans))
 	{
-		path = (Path *) create_material_path(rel, path);
+		path = (Path *) create_material_path(rel, path, true);
 	}
 
 	add_path(rel, path);
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index a39cc793b4d..51940aec820 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -275,6 +275,7 @@ cost_seqscan(Path *path, PlannerInfo *root,
 	double		spc_seq_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = PGS_SEQSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -327,8 +328,11 @@ cost_seqscan(Path *path, PlannerInfo *root,
 		 */
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	path->disabled_nodes = enable_seqscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
 }
@@ -354,6 +358,7 @@ cost_samplescan(Path *path, PlannerInfo *root,
 				spc_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations with tablesample clauses */
 	Assert(baserel->relid > 0);
@@ -401,7 +406,11 @@ cost_samplescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -440,7 +449,8 @@ cost_gather(GatherPath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows;
 
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost;
 	path->path.total_cost = (startup_cost + run_cost);
 }
@@ -506,8 +516,8 @@ cost_gather_merge(GatherMergePath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows * 1.05;
 
-	path->path.disabled_nodes = input_disabled_nodes
-		+ (enable_gathermerge ? 0 : 1);
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER_MERGE) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost + input_startup_cost;
 	path->path.total_cost = (startup_cost + run_cost + input_total_cost);
 }
@@ -557,6 +567,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	double		pages_fetched;
 	double		rand_heap_pages;
 	double		index_pages;
+	uint64		enable_mask;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo) &&
@@ -588,8 +599,11 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 											  path->indexclauses);
 	}
 
-	/* we don't need to check enable_indexonlyscan; indxpath.c does that */
-	path->path.disabled_nodes = enable_indexscan ? 0 : 1;
+	/* is this scan type disabled? */
+	enable_mask = (indexonly ? PGS_INDEXONLYSCAN : PGS_INDEXSCAN)
+		| (path->path.parallel_workers == 0 ? PGS_CONSIDER_NONPARTIAL : 0);
+	path->path.disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 
 	/*
 	 * Call index-access-method-specific code to estimate the processing cost
@@ -1010,6 +1024,7 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	double		spc_seq_page_cost,
 				spc_random_page_cost;
 	double		T;
+	uint64		enable_mask = PGS_BITMAPSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo));
@@ -1075,6 +1090,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 
 	run_cost += cpu_run_cost;
@@ -1083,7 +1100,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = enable_bitmapscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1240,6 +1258,7 @@ cost_tidscan(Path *path, PlannerInfo *root,
 	double		ntuples;
 	ListCell   *l;
 	double		spc_random_page_cost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1261,10 +1280,10 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 		/*
 		 * We must use a TID scan for CurrentOfExpr; in any other case, we
-		 * should be generating a TID scan only if enable_tidscan=true. Also,
-		 * if CurrentOfExpr is the qual, there should be only one.
+		 * should be generating a TID scan only if TID scans are allowed.
+		 * Also, if CurrentOfExpr is the qual, there should be only one.
 		 */
-		Assert(enable_tidscan || IsA(qual, CurrentOfExpr));
+		Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0 || IsA(qual, CurrentOfExpr));
 		Assert(list_length(tidquals) == 1 || !IsA(qual, CurrentOfExpr));
 
 		if (IsA(qual, ScalarArrayOpExpr))
@@ -1316,10 +1335,14 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 	/*
 	 * There are assertions above verifying that we only reach this function
-	 * either when enable_tidscan=true or when the TID scan is the only legal
-	 * path, so it's safe to set disabled_nodes to zero here.
+	 * either when baserel->pgs_mask includes PGS_TIDSCAN or when the TID scan
+	 * is the only legal path, so we only need to consider the effects of
+	 * PGS_CONSIDER_NONPARTIAL here.
 	 */
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1350,6 +1373,7 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	double		nseqpages;
 	double		spc_random_page_cost;
 	double		spc_seq_page_cost;
+	uint64		enable_mask = PGS_TIDSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1428,8 +1452,15 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
 
-	/* we should not generate this path type when enable_tidscan=false */
-	Assert(enable_tidscan);
+	/*
+	 * We should not generate this path type when PGS_TIDSCAN is unset, but we
+	 * might need to disable this path due to PGS_CONSIDER_NONPARTIAL.
+	 */
+	Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0);
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->disabled_nodes = 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
@@ -1453,6 +1484,7 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	List	   *qpquals;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are subqueries */
 	Assert(baserel->relid > 0);
@@ -1483,7 +1515,10 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	 * SubqueryScan node, plus cpu_tuple_cost to account for selection and
 	 * projection overhead.
 	 */
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	if (path->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ (((baserel->pgs_mask & enable_mask) != enable_mask) ? 1 : 0);
 	path->path.startup_cost = path->subpath->startup_cost;
 	path->path.total_cost = path->subpath->total_cost;
 
@@ -1534,6 +1569,7 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1574,7 +1610,10 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1596,6 +1635,7 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1631,7 +1671,10 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1651,6 +1694,7 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are values lists */
 	Assert(baserel->relid > 0);
@@ -1679,7 +1723,10 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1702,6 +1749,7 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are CTEs */
 	Assert(baserel->relid > 0);
@@ -1727,7 +1775,10 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1744,6 +1795,7 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are Tuplestores */
 	Assert(baserel->relid > 0);
@@ -1765,7 +1817,10 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	cpu_per_tuple += cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1782,6 +1837,7 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to RTE_RESULT base relations */
 	Assert(baserel->relid > 0);
@@ -1800,7 +1856,10 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1818,6 +1877,7 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	Cost		startup_cost;
 	Cost		total_cost;
 	double		total_rows;
+	uint64		enable_mask = 0;
 
 	/* We probably have decent estimates for the non-recursive term */
 	startup_cost = nrterm->startup_cost;
@@ -1840,7 +1900,10 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	 */
 	total_cost += cpu_tuple_cost * total_rows;
 
-	runion->disabled_nodes = nrterm->disabled_nodes + rterm->disabled_nodes;
+	if (runion->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	runion->disabled_nodes =
+		(runion->parent->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	runion->startup_cost = startup_cost;
 	runion->total_cost = total_cost;
 	runion->rows = total_rows;
@@ -2110,7 +2173,11 @@ cost_incremental_sort(Path *path,
 
 	path->rows = input_tuples;
 
-	/* should not generate these paths when enable_incremental_sort=false */
+	/*
+	 * We should not generate these paths when enable_incremental_sort=false.
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	Assert(enable_incremental_sort);
 	path->disabled_nodes = input_disabled_nodes;
 
@@ -2148,6 +2215,10 @@ cost_sort(Path *path, PlannerInfo *root,
 
 	startup_cost += input_cost;
 
+	/*
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	path->rows = tuples;
 	path->disabled_nodes = input_disabled_nodes + (enable_sort ? 0 : 1);
 	path->startup_cost = startup_cost;
@@ -2239,9 +2310,15 @@ append_nonpartial_cost(List *subpaths, int numpaths, int parallel_workers)
 void
 cost_append(AppendPath *apath, PlannerInfo *root)
 {
+	RelOptInfo *rel = apath->path.parent;
 	ListCell   *l;
+	uint64		enable_mask = PGS_APPEND;
+
+	if (apath->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	apath->path.disabled_nodes = 0;
+	apath->path.disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	apath->path.startup_cost = 0;
 	apath->path.total_cost = 0;
 	apath->path.rows = 0;
@@ -2451,11 +2528,16 @@ cost_merge_append(Path *path, PlannerInfo *root,
 				  Cost input_startup_cost, Cost input_total_cost,
 				  double tuples)
 {
+	RelOptInfo *rel = path->parent;
 	Cost		startup_cost = 0;
 	Cost		run_cost = 0;
 	Cost		comparison_cost;
 	double		N;
 	double		logN;
+	uint64		enable_mask = PGS_MERGE_APPEND;
+
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/*
 	 * Avoid log(0)...
@@ -2478,7 +2560,9 @@ cost_merge_append(Path *path, PlannerInfo *root,
 	 */
 	run_cost += cpu_tuple_cost * APPEND_CPU_COST_MULTIPLIER * tuples;
 
-	path->disabled_nodes = input_disabled_nodes;
+	path->disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
+	path->disabled_nodes += input_disabled_nodes;
 	path->startup_cost = startup_cost + input_startup_cost;
 	path->total_cost = startup_cost + run_cost + input_total_cost;
 }
@@ -2497,7 +2581,7 @@ cost_merge_append(Path *path, PlannerInfo *root,
  */
 void
 cost_material(Path *path,
-			  int input_disabled_nodes,
+			  bool enabled, int input_disabled_nodes,
 			  Cost input_startup_cost, Cost input_total_cost,
 			  double tuples, int width)
 {
@@ -2506,6 +2590,11 @@ cost_material(Path *path,
 	double		nbytes = relation_byte_size(tuples, width);
 	double		work_mem_bytes = work_mem * (Size) 1024;
 
+	if (path->parallel_workers == 0 &&
+		path->parent != NULL &&
+		(path->parent->pgs_mask & PGS_CONSIDER_NONPARTIAL) == 0)
+		enabled = false;
+
 	path->rows = tuples;
 
 	/*
@@ -2535,7 +2624,7 @@ cost_material(Path *path,
 		run_cost += seq_page_cost * npages;
 	}
 
-	path->disabled_nodes = input_disabled_nodes + (enable_material ? 0 : 1);
+	path->disabled_nodes = input_disabled_nodes + (enabled ? 0 : 1);
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -3287,7 +3376,7 @@ cost_group(Path *path, PlannerInfo *root,
  */
 void
 initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
-					  JoinType jointype,
+					  JoinType jointype, uint64 enable_mask,
 					  Path *outer_path, Path *inner_path,
 					  JoinPathExtraData *extra)
 {
@@ -3301,7 +3390,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Cost		inner_rescan_run_cost;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_nestloop ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
@@ -3701,7 +3790,19 @@ initial_cost_mergejoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Assert(outerstartsel <= outerendsel);
 	Assert(innerstartsel <= innerendsel);
 
-	disabled_nodes = enable_mergejoin ? 0 : 1;
+	/*
+	 * We don't decide whether to materialize the inner path until we get to
+	 * final_cost_mergejoin(), so we don't know whether to check the pgs_mask
+	 * again PGS_MERGEJOIN_PLAIN or PGS_MERGEJOIN_MATERIALIZE. Instead, we
+	 * just account for any child nodes here and assume that this node is not
+	 * itslef disabled; we can sort out the details in final_cost_mergejoin().
+	 *
+	 * (We could be more precise here by setting disabled_nodes to 1 at this
+	 * stage if both PGS_MERGEJOIN_PLAIN and PGS_MERGEJOIN_MATERIALIZE are
+	 * disabled, but that seems to against the idea of making this function
+	 * produce a quick, optimistic approximation of the final cost.)
+	 */
+	disabled_nodes = 0;
 
 	/* cost of source data */
 
@@ -3880,9 +3981,7 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	double		mergejointuples,
 				rescannedtuples;
 	double		rescanratio;
-
-	/* Set the number of disabled nodes. */
-	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+	uint64		enable_mask = 0;
 
 	/* Protect some assumptions below that rowcounts aren't zero */
 	if (inner_path_rows <= 0)
@@ -4012,16 +4111,20 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 		path->materialize_inner = false;
 
 	/*
-	 * Prefer materializing if it looks cheaper, unless the user has asked to
-	 * suppress materialization.
+	 * If merge joins with materialization are enabled, then choose
+	 * materialization if either (a) it looks cheaper or (b) merge joins
+	 * without materialization are disabled.
 	 */
-	else if (enable_material && mat_inner_cost < bare_inner_cost)
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 (mat_inner_cost < bare_inner_cost ||
+			  (extra->pgs_mask & PGS_MERGEJOIN_PLAIN) == 0))
 		path->materialize_inner = true;
 
 	/*
-	 * Even if materializing doesn't look cheaper, we *must* do it if the
-	 * inner path is to be used directly (without sorting) and it doesn't
-	 * support mark/restore.
+	 * Regardless of what plan shapes are enabled and what the costs seem to
+	 * be, we *must* materialize it if the inner path is to be used directly
+	 * (without sorting) and it doesn't support mark/restore. Planner failure
+	 * is not an option!
 	 *
 	 * Since the inner side must be ordered, and only Sorts and IndexScans can
 	 * create order to begin with, and they both support mark/restore, you
@@ -4029,10 +4132,6 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * merge joins can *preserve* the order of their inputs, so they can be
 	 * selected as the input of a mergejoin, and they don't support
 	 * mark/restore at present.
-	 *
-	 * We don't test the value of enable_material here, because
-	 * materialization is required for correctness in this case, and turning
-	 * it off does not entitle us to deliver an invalid plan.
 	 */
 	else if (innersortkeys == NIL &&
 			 !ExecSupportsMarkRestore(inner_path))
@@ -4046,10 +4145,11 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * though.
 	 *
 	 * Since materialization is a performance optimization in this case,
-	 * rather than necessary for correctness, we skip it if enable_material is
-	 * off.
+	 * rather than necessary for correctness, we skip it if materialization is
+	 * switched off.
 	 */
-	else if (enable_material && innersortkeys != NIL &&
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 innersortkeys != NIL &&
 			 relation_byte_size(inner_path_rows,
 								inner_path->pathtarget->width) >
 			 work_mem * (Size) 1024)
@@ -4057,11 +4157,29 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	else
 		path->materialize_inner = false;
 
-	/* Charge the right incremental cost for the chosen case */
+	/* Get the number of disabled nodes, not yet including this one. */
+	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+
+	/*
+	 * Charge the right incremental cost for the chosen case, and update
+	 * enable_mask as appropriate.
+	 */
 	if (path->materialize_inner)
+	{
 		run_cost += mat_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
 	else
+	{
 		run_cost += bare_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_PLAIN;
+	}
+
+	/* Incremental count of disabled nodes if this node is disabled. */
+	if (path->jpath.path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	if ((extra->pgs_mask & enable_mask) != enable_mask)
+		++path->jpath.path.disabled_nodes;
 
 	/* CPU costs */
 
@@ -4199,9 +4317,13 @@ initial_cost_hashjoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	int			numbatches;
 	int			num_skew_mcvs;
 	size_t		space_allowed;	/* unused */
+	uint64		enable_mask = PGS_HASHJOIN;
+
+	if (outer_path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_hashjoin ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index 5d4f81ee77e..8922b68033a 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -2232,8 +2232,8 @@ check_index_only(RelOptInfo *rel, IndexOptInfo *index)
 	ListCell   *lc;
 	int			i;
 
-	/* Index-only scans must be enabled */
-	if (!enable_indexonlyscan)
+	/* If we're not allowed to consider index-only scans, give up now */
+	if ((rel->pgs_mask & PGS_CONSIDER_INDEXONLY) == 0)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index ea5b6415186..82dab3d6004 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -29,8 +29,9 @@
 #include "utils/lsyscache.h"
 #include "utils/typcache.h"
 
-/* Hook for plugins to get control in add_paths_to_joinrel() */
+/* Hooks for plugins to get control in add_paths_to_joinrel() */
 set_join_pathlist_hook_type set_join_pathlist_hook = NULL;
+join_path_setup_hook_type join_path_setup_hook = NULL;
 
 /*
  * Paths parameterized by a parent rel can be considered to be parameterized
@@ -151,6 +152,33 @@ add_paths_to_joinrel(PlannerInfo *root,
 	extra.mergeclause_list = NIL;
 	extra.sjinfo = sjinfo;
 	extra.param_source_rels = NULL;
+	extra.pgs_mask = joinrel->pgs_mask;
+
+	/*
+	 * Give extensions a chance to take control. In particular, an extension
+	 * might want to modify extra.pgs_mask. It's possible to override pgs_mask
+	 * on a query-wide basis using join_search_hook, or for a particular
+	 * relation using joinrel_setup_hook, but extensions that want to provide
+	 * different advice for the same joinrel based on the choice of innerrel
+	 * and outerrel will need to use this hook.
+	 *
+	 * A very simple way for an extension to use this hook is to set
+	 * extra.pgs_mask &= ~PGS_JOIN_ANY, if it simply doesn't want any of the
+	 * paths generated by this call to add_paths_to_joinrel() to be selected.
+	 * An extension could use this technique to constrain the join order,
+	 * since it could thereby arrange to reject all paths from join orders
+	 * that it does not like. An extension can also selectively clear bits
+	 * from extra.pgs_mask to rule out specific techniques for specific joins,
+	 * or could even set additional bits to re-allow methods disabled at some
+	 * higher level.
+	 *
+	 * NB: Below this point, this function should be careful to reference
+	 * extra.pgs_mask rather than rel->pgs_mask to avoid disregarding any
+	 * changes made by the hook we're about to call.
+	 */
+	if (join_path_setup_hook)
+		join_path_setup_hook(root, joinrel, outerrel, innerrel,
+							 jointype, &extra);
 
 	/*
 	 * See if the inner relation is provably unique for this outer rel.
@@ -210,10 +238,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * Find potential mergejoin clauses.  We can skip this if we are not
 	 * interested in doing a mergejoin.  However, mergejoin may be our only
-	 * way of implementing a full outer join, so override enable_mergejoin if
-	 * it's a full join.
+	 * way of implementing a full outer join, so in that case we don't care
+	 * whether mergejoins are disabled.
 	 */
-	if (enable_mergejoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_MERGEJOIN_ANY) != 0 || jointype == JOIN_FULL)
 		extra.mergeclause_list = select_mergejoin_clauses(root,
 														  joinrel,
 														  outerrel,
@@ -321,10 +349,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 
 	/*
 	 * 4. Consider paths where both outer and inner relations must be hashed
-	 * before being joined.  As above, disregard enable_hashjoin for full
-	 * joins, because there may be no other alternative.
+	 * before being joined.  As above, when it's a full join, we must try this
+	 * even when the path type is disabled, because it may be our only option.
 	 */
-	if (enable_hashjoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_HASHJOIN) != 0 || jointype == JOIN_FULL)
 		hash_inner_and_outer(root, joinrel, outerrel, innerrel,
 							 jointype, &extra);
 
@@ -333,7 +361,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	 * to the same server and assigned to the same user to check access
 	 * permissions as, give the FDW a chance to push down joins.
 	 */
-	if (joinrel->fdwroutine &&
+	if ((extra.pgs_mask & PGS_FOREIGNJOIN) != 0 && joinrel->fdwroutine &&
 		joinrel->fdwroutine->GetForeignJoinPaths)
 		joinrel->fdwroutine->GetForeignJoinPaths(root, joinrel,
 												 outerrel, innerrel,
@@ -342,8 +370,13 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * 6. Finally, give extensions a chance to manipulate the path list.  They
 	 * could add new paths (such as CustomPaths) by calling add_path(), or
-	 * add_partial_path() if parallel aware.  They could also delete or modify
-	 * paths added by the core code.
+	 * add_partial_path() if parallel aware.
+	 *
+	 * In theory, extensions could also use this hook to delete or modify
+	 * paths added by the core code, but in practice this is difficult to make
+	 * work, since it's too late to get back any paths that have already been
+	 * discarded by add_path() or add_partial_path(). If you're trying to
+	 * suppress paths, consider using join_path_setup_hook instead.
 	 */
 	if (set_join_pathlist_hook)
 		set_join_pathlist_hook(root, joinrel, outerrel, innerrel,
@@ -690,7 +723,7 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel,
 	List	   *ph_lateral_vars;
 
 	/* Obviously not if it's disabled */
-	if (!enable_memoize)
+	if ((extra->pgs_mask & PGS_NESTLOOP_MEMOIZE) == 0)
 		return NULL;
 
 	/*
@@ -845,6 +878,7 @@ try_nestloop_path(PlannerInfo *root,
 				  Path *inner_path,
 				  List *pathkeys,
 				  JoinType jointype,
+				  uint64 nestloop_subtype,
 				  JoinPathExtraData *extra)
 {
 	Relids		required_outer;
@@ -927,6 +961,7 @@ try_nestloop_path(PlannerInfo *root,
 	 * methodology worthwhile.
 	 */
 	initial_cost_nestloop(root, &workspace, jointype,
+						  nestloop_subtype | PGS_CONSIDER_NONPARTIAL,
 						  outer_path, inner_path, extra);
 
 	if (add_path_precheck(joinrel, workspace.disabled_nodes,
@@ -964,6 +999,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 						  Path *inner_path,
 						  List *pathkeys,
 						  JoinType jointype,
+						  uint64 nestloop_subtype,
 						  JoinPathExtraData *extra)
 {
 	JoinCostWorkspace workspace;
@@ -1011,7 +1047,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 	 * Before creating a path, get a quick lower bound on what it is likely to
 	 * cost.  Bail out right away if it looks terrible.
 	 */
-	initial_cost_nestloop(root, &workspace, jointype,
+	initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype,
 						  outer_path, inner_path, extra);
 	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
 								   workspace.total_cost, pathkeys))
@@ -1859,14 +1895,14 @@ match_unsorted_outer(PlannerInfo *root,
 	if (nestjoinOK)
 	{
 		/*
-		 * Consider materializing the cheapest inner path, unless
-		 * enable_material is off or the path in question materializes its
-		 * output anyway.
+		 * Consider materializing the cheapest inner path, unless that is
+		 * disabled or the path in question materializes its output anyway.
 		 */
-		if (enable_material && inner_cheapest_total != NULL &&
+		if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+			inner_cheapest_total != NULL &&
 			!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 			matpath = (Path *)
-				create_material_path(innerrel, inner_cheapest_total);
+				create_material_path(innerrel, inner_cheapest_total, true);
 	}
 
 	foreach(lc1, outerrel->pathlist)
@@ -1909,6 +1945,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  innerpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_PLAIN,
 								  extra);
 
 				/*
@@ -1925,6 +1962,7 @@ match_unsorted_outer(PlannerInfo *root,
 									  mpath,
 									  merge_pathkeys,
 									  jointype,
+									  PGS_NESTLOOP_MEMOIZE,
 									  extra);
 			}
 
@@ -1936,6 +1974,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  matpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_MATERIALIZE,
 								  extra);
 		}
 
@@ -2052,16 +2091,17 @@ consider_parallel_nestloop(PlannerInfo *root,
 
 	/*
 	 * Consider materializing the cheapest inner path, unless: 1)
-	 * enable_material is off, 2) the cheapest inner path is not
+	 * materialization is disabled here, 2) the cheapest inner path is not
 	 * parallel-safe, 3) the cheapest inner path is parameterized by the outer
 	 * rel, or 4) the cheapest inner path materializes its output anyway.
 	 */
-	if (enable_material && inner_cheapest_total->parallel_safe &&
+	if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+		inner_cheapest_total->parallel_safe &&
 		!PATH_PARAM_BY_REL(inner_cheapest_total, outerrel) &&
 		!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 	{
 		matpath = (Path *)
-			create_material_path(innerrel, inner_cheapest_total);
+			create_material_path(innerrel, inner_cheapest_total, true);
 		Assert(matpath->parallel_safe);
 	}
 
@@ -2091,7 +2131,8 @@ consider_parallel_nestloop(PlannerInfo *root,
 				continue;
 
 			try_partial_nestloop_path(root, joinrel, outerpath, innerpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_PLAIN, extra);
 
 			/*
 			 * Try generating a memoize path and see if that makes the nested
@@ -2102,13 +2143,15 @@ consider_parallel_nestloop(PlannerInfo *root,
 									 extra);
 			if (mpath != NULL)
 				try_partial_nestloop_path(root, joinrel, outerpath, mpath,
-										  pathkeys, jointype, extra);
+										  pathkeys, jointype,
+										  PGS_NESTLOOP_MEMOIZE, extra);
 		}
 
 		/* Also consider materialized form of the cheapest inner path */
 		if (matpath != NULL)
 			try_partial_nestloop_path(root, joinrel, outerpath, matpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_MATERIALIZE, extra);
 	}
 }
 
diff --git a/src/backend/optimizer/path/tidpath.c b/src/backend/optimizer/path/tidpath.c
index 3ddbc10bbdf..150115c293f 100644
--- a/src/backend/optimizer/path/tidpath.c
+++ b/src/backend/optimizer/path/tidpath.c
@@ -499,18 +499,19 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	List	   *tidquals;
 	List	   *tidrangequals;
 	bool		isCurrentOf;
+	bool		enabled = (rel->pgs_mask & PGS_TIDSCAN) != 0;
 
 	/*
 	 * If any suitable quals exist in the rel's baserestrict list, generate a
 	 * plain (unparameterized) TidPath with them.
 	 *
-	 * We skip this when enable_tidscan = false, except when the qual is
+	 * We skip this when TID scans are disabled, except when the qual is
 	 * CurrentOfExpr. In that case, a TID scan is the only correct path.
 	 */
 	tidquals = TidQualFromRestrictInfoList(root, rel->baserestrictinfo, rel,
 										   &isCurrentOf);
 
-	if (tidquals != NIL && (enable_tidscan || isCurrentOf))
+	if (tidquals != NIL && (enabled || isCurrentOf))
 	{
 		/*
 		 * This path uses no join clauses, but it could still have required
@@ -532,7 +533,7 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	}
 
 	/* Skip the rest if TID scans are disabled. */
-	if (!enable_tidscan)
+	if (!enabled)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index e3f27a586ca..66d491ecb10 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -6503,7 +6503,7 @@ Plan *
 materialize_finished_plan(Plan *subplan)
 {
 	Plan	   *matplan;
-	Path		matpath;		/* dummy for result of cost_material */
+	Path		matpath;		/* dummy for cost_material */
 	Cost		initplan_cost;
 	bool		unsafe_initplans;
 
@@ -6525,7 +6525,9 @@ materialize_finished_plan(Plan *subplan)
 	subplan->total_cost -= initplan_cost;
 
 	/* Set cost data */
+	matpath.parent = NULL;
 	cost_material(&matpath,
+				  enable_material,
 				  subplan->disabled_nodes,
 				  subplan->startup_cost,
 				  subplan->total_cost,
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 94e1ac96ed9..36a29355104 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -462,6 +462,53 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 		tuple_fraction = 0.0;
 	}
 
+	/*
+	 * Compute the initial path generation strategy mask.
+	 *
+	 * Some strategies, such as PGS_FOREIGNJOIN, have no corresponding enable_*
+	 * GUC, and so the corresponding bits are always set in the default
+	 * strategy mask.
+	 *
+	 * It may seem surprising that enable_indexscan sets both PGS_INDEXSCAN
+	 * and PGS_INDEXONLYSCAN. However, the historical behavior of this GUC
+	 * corresponds to this exactly: enable_indexscan=off disables both
+	 * index-scan and index-only scan paths, whereas enable_indexonlyscan=off
+	 * converts the index-only scan paths that we would have considered into
+	 * index scan paths.
+	 */
+	glob->default_pgs_mask = PGS_APPEND | PGS_MERGE_APPEND | PGS_FOREIGNJOIN |
+		PGS_GATHER | PGS_CONSIDER_NONPARTIAL;
+	if (enable_tidscan)
+		glob->default_pgs_mask |= PGS_TIDSCAN;
+	if (enable_seqscan)
+		glob->default_pgs_mask |= PGS_SEQSCAN;
+	if (enable_indexscan)
+		glob->default_pgs_mask |= PGS_INDEXSCAN | PGS_INDEXONLYSCAN;
+	if (enable_indexonlyscan)
+		glob->default_pgs_mask |= PGS_CONSIDER_INDEXONLY;
+	if (enable_bitmapscan)
+		glob->default_pgs_mask |= PGS_BITMAPSCAN;
+	if (enable_mergejoin)
+	{
+		glob->default_pgs_mask |= PGS_MERGEJOIN_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
+	if (enable_nestloop)
+	{
+		glob->default_pgs_mask |= PGS_NESTLOOP_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MATERIALIZE;
+		if (enable_memoize)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MEMOIZE;
+	}
+	if (enable_hashjoin)
+		glob->default_pgs_mask |= PGS_HASHJOIN;
+	if (enable_gathermerge)
+		glob->default_pgs_mask |= PGS_GATHER_MERGE;
+	if (enable_partitionwise_join)
+		glob->default_pgs_mask |= PGS_CONSIDER_PARTITIONWISE;
+
 	/* Allow plugins to take control after we've initialized "glob" */
 	if (planner_setup_hook)
 		(*planner_setup_hook) (glob, parse, query_string, &tuple_fraction, es);
@@ -3954,6 +4001,9 @@ make_grouping_rel(PlannerInfo *root, RelOptInfo *input_rel,
 		is_parallel_safe(root, havingQual))
 		grouped_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed */
+	grouped_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the grouped rel.
 	 */
@@ -5348,6 +5398,9 @@ create_ordered_paths(PlannerInfo *root,
 	if (input_rel->consider_parallel && target_parallel_safe)
 		ordered_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed. */
+	ordered_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the ordered_rel.
 	 */
@@ -7428,6 +7481,7 @@ create_partial_grouping_paths(PlannerInfo *root,
 											grouped_rel->relids);
 	partially_grouped_rel->consider_parallel =
 		grouped_rel->consider_parallel;
+	partially_grouped_rel->pgs_mask = grouped_rel->pgs_mask;
 	partially_grouped_rel->reloptkind = grouped_rel->reloptkind;
 	partially_grouped_rel->serverid = grouped_rel->serverid;
 	partially_grouped_rel->userid = grouped_rel->userid;
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 33ce34f0088..7dd9a7c4609 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1659,7 +1659,7 @@ create_group_result_path(PlannerInfo *root, RelOptInfo *rel,
  *	  pathnode.
  */
 MaterialPath *
-create_material_path(RelOptInfo *rel, Path *subpath)
+create_material_path(RelOptInfo *rel, Path *subpath, bool enabled)
 {
 	MaterialPath *pathnode = makeNode(MaterialPath);
 
@@ -1678,6 +1678,7 @@ create_material_path(RelOptInfo *rel, Path *subpath)
 	pathnode->subpath = subpath;
 
 	cost_material(&pathnode->path,
+				  enabled,
 				  subpath->disabled_nodes,
 				  subpath->startup_cost,
 				  subpath->total_cost,
@@ -1730,8 +1731,15 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath,
 	pathnode->est_unique_keys = 0.0;
 	pathnode->est_hit_ratio = 0.0;
 
-	/* we should not generate this path type when enable_memoize=false */
-	Assert(enable_memoize);
+	/*
+	 * We should not be asked to generate this path type when memoization is
+	 * disabled, so set our count of disabled nodes equal to the subpath's
+	 * count.
+	 *
+	 * It would be nice to also Assert that memoization is enabled, but the
+	 * value of enable_memoize is not controlling: what we would need to check
+	 * is that the JoinPathExtraData's pgs_mask included PGS_NESTLOOP_MEMOIZE.
+	 */
 	pathnode->path.disabled_nodes = subpath->disabled_nodes;
 
 	/*
@@ -3965,13 +3973,16 @@ reparameterize_path(PlannerInfo *root, Path *path,
 			{
 				MaterialPath *mpath = (MaterialPath *) path;
 				Path	   *spath = mpath->subpath;
+				bool		enabled;
 
 				spath = reparameterize_path(root, spath,
 											required_outer,
 											loop_count);
+				enabled =
+					(mpath->path.disabled_nodes <= spath->disabled_nodes);
 				if (spath == NULL)
 					return NULL;
-				return (Path *) create_material_path(rel, spath);
+				return (Path *) create_material_path(rel, spath, enabled);
 			}
 		case T_Memoize:
 			{
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index e553afb7f01..138a88281e0 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -576,6 +576,9 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 	 * Allow a plugin to editorialize on the info we obtained from the
 	 * catalogs.  Actions might include altering the assumed relation size,
 	 * removing an index, or adding a hypothetical index to the indexlist.
+	 *
+	 * An extension can also modify rel->pgs_mask here to control path
+	 * generation.
 	 */
 	if (get_relation_info_hook)
 		(*get_relation_info_hook) (root, relationObjectId, inhparent, rel);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 405f4dae109..1d2d7292fe6 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -47,6 +47,9 @@ typedef struct JoinHashEntry
 	RelOptInfo *join_rel;
 } JoinHashEntry;
 
+/* Hook for plugins to get control during joinrel setup */
+joinrel_setup_hook_type joinrel_setup_hook = NULL;
+
 static void build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 								RelOptInfo *input_rel,
 								SpecialJoinInfo *sjinfo,
@@ -225,6 +228,7 @@ build_simple_rel(PlannerInfo *root, int relid, RelOptInfo *parent)
 	rel->consider_startup = (root->tuple_fraction > 0);
 	rel->consider_param_startup = false;	/* might get changed later */
 	rel->consider_parallel = false; /* might get changed later */
+	rel->pgs_mask = root->glob->default_pgs_mask;
 	rel->reltarget = create_empty_pathtarget();
 	rel->pathlist = NIL;
 	rel->ppilist = NIL;
@@ -822,6 +826,7 @@ build_join_rel(PlannerInfo *root,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -934,10 +939,6 @@ build_join_rel(PlannerInfo *root,
 	 */
 	joinrel->has_eclass_joins = has_relevant_eclass_joinclause(root, joinrel);
 
-	/* Store the partition information. */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/*
 	 * Set estimates of the joinrel's size.
 	 */
@@ -963,6 +964,18 @@ build_join_rel(PlannerInfo *root,
 		is_parallel_safe(root, (Node *) joinrel->reltarget->exprs))
 		joinrel->consider_parallel = true;
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Store the partition information. */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* Add the joinrel to the PlannerInfo. */
 	add_join_rel(root, joinrel);
 
@@ -1019,6 +1032,7 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -1102,10 +1116,6 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	 */
 	joinrel->has_eclass_joins = parent_joinrel->has_eclass_joins;
 
-	/* Is the join between partitions itself partitioned? */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/* Child joinrel is parallel safe if parent is parallel safe. */
 	joinrel->consider_parallel = parent_joinrel->consider_parallel;
 
@@ -1113,6 +1123,20 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	set_joinrel_size_estimates(root, joinrel, outer_rel, inner_rel,
 							   sjinfo, restrictlist);
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel,
+	 * although the latter would be better done in the parent joinrel rather
+	 * than here.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Is the join between partitions itself partitioned? */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* We build the join only once. */
 	Assert(!find_join_rel(root, joinrel->relids));
 
@@ -1602,6 +1626,7 @@ fetch_upper_rel(PlannerInfo *root, UpperRelationKind kind, Relids relids)
 	upperrel = makeNode(RelOptInfo);
 	upperrel->reloptkind = RELOPT_UPPER_REL;
 	upperrel->relids = bms_copy(relids);
+	upperrel->pgs_mask = root->glob->default_pgs_mask;
 
 	/* cheap startup cost is interesting iff not all tuples to be retrieved */
 	upperrel->consider_startup = (root->tuple_fraction > 0);
@@ -2118,7 +2143,7 @@ build_joinrel_partition_info(PlannerInfo *root,
 	PartitionScheme part_scheme;
 
 	/* Nothing to do if partitionwise join technique is disabled. */
-	if (!enable_partitionwise_join)
+	if ((joinrel->pgs_mask & PGS_CONSIDER_PARTITIONWISE) == 0)
 	{
 		Assert(!IS_PARTITIONED_REL(joinrel));
 		return;
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index ce623da71cc..c18424b458a 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -22,6 +22,79 @@
 #include "nodes/parsenodes.h"
 #include "storage/block.h"
 
+/*
+ * Path generation strategies.
+ *
+ * These constants are used to specify the set of strategies that the planner
+ * should use, either for the query as a whole or for a specific baserel or
+ * joinrel. The various planner-related enable_* GUCs are used to set the
+ * PlannerGlobal's default_pgs_mask, and that in turn is used to set each
+ * RelOptInfo's pgs_mask. In both cases, extensions can use hooks to modify the
+ * default value.  Not every strategy listed here has a corresponding enable_*
+ * GUC; those that don't are always allowed unless disabled by an extension.
+ * Not all strategies are relevant for every RelOptInfo; e.g. PGS_SEQSCAN
+ * doesn't affect joinrels one way or the other.
+ *
+ * In most cases, disabling a path generation strategy merely means that any
+ * paths generated using that strategy are marked as disabled, but in some
+ * cases, path generation is skipped altogether. The latter strategy is only
+ * permissible when it can't result in planner failure -- for instance, we
+ * couldn't do this for sequential scans on a plain rel, because there might
+ * not be any other possible path. Nevertheless, the behaviors in each
+ * individual case are to some extent the result of historical accident,
+ * chosen to match the preexisting behaviors of the enable_* GUCs.
+ *
+ * In a few cases, we have more than one bit for the same strategy, controlling
+ * different aspects of the planner behavior. When PGS_CONSIDER_INDEXONLY is
+ * unset, we don't even consider index-only scans, and any such scans that
+ * would have been generated become index scans instead. On the other hand,
+ * unsetting PGS_INDEXSCAN or PGS_INDEXONLYSCAN causes generated paths of the
+ * corresponding types to be marked as disabled. Similarly, unsetting
+ * PGS_CONSIDER_PARTITIONWISE prevents any sort of thinking about partitionwise
+ * joins for the current rel, which incidentally will preclude higher-level
+ * joinrels from building parititonwise paths using paths taken from the
+ * current rel's children. On the other hand, unsetting PGS_APPEND or
+ * PGS_MERGE_APPEND will only arrange to disable paths of the corresponding
+ * types if they are generated at the level of the current rel.
+ *
+ * Finally, unsetting PGS_CONSIDER_NONPARTIAL disables all non-partial paths
+ * except those that use Gather or Gather Merge. In most other cases, a
+ * plugin can nudge the planner toward a particular strategy by disabling
+ * all of the others, but that doesn't work here: unsetting PGS_SEQSCAN,
+ * for instance, would disable both partial and non-partial sequential scans.
+ */
+#define PGS_SEQSCAN					0x00000001
+#define PGS_INDEXSCAN				0x00000002
+#define PGS_INDEXONLYSCAN			0x00000004
+#define PGS_BITMAPSCAN				0x00000008
+#define PGS_TIDSCAN					0x00000010
+#define PGS_FOREIGNJOIN				0x00000020
+#define PGS_MERGEJOIN_PLAIN			0x00000040
+#define PGS_MERGEJOIN_MATERIALIZE	0x00000080
+#define PGS_NESTLOOP_PLAIN			0x00000100
+#define PGS_NESTLOOP_MATERIALIZE	0x00000200
+#define PGS_NESTLOOP_MEMOIZE		0x00000400
+#define PGS_HASHJOIN				0x00000800
+#define PGS_APPEND					0x00001000
+#define PGS_MERGE_APPEND			0x00002000
+#define PGS_GATHER					0x00004000
+#define PGS_GATHER_MERGE			0x00008000
+#define PGS_CONSIDER_INDEXONLY		0x00010000
+#define PGS_CONSIDER_PARTITIONWISE	0x00020000
+#define PGS_CONSIDER_NONPARTIAL		0x00040000
+
+/*
+ * Convenience macros for useful combination of the bits defined above.
+ */
+#define PGS_SCAN_ANY		\
+	(PGS_SEQSCAN | PGS_INDEXSCAN | PGS_INDEXONLYSCAN | PGS_BITMAPSCAN | \
+	 PGS_TIDSCAN)
+#define PGS_MERGEJOIN_ANY	\
+	(PGS_MERGEJOIN_PLAIN | PGS_MERGEJOIN_MATERIALIZE)
+#define PGS_NESTLOOP_ANY	\
+	(PGS_NESTLOOP_PLAIN | PGS_NESTLOOP_MATERIALIZE | PGS_NESTLOOP_MEMOIZE)
+#define PGS_JOIN_ANY		\
+	(PGS_FOREIGNJOIN | PGS_MERGEJOIN_ANY | PGS_NESTLOOP_ANY | PGS_HASHJOIN)
 
 /*
  * Relids
@@ -186,6 +259,9 @@ typedef struct PlannerGlobal
 	/* worst PROPARALLEL hazard level */
 	char		maxParallelHazard;
 
+	/* mask of allowed path generation strategies */
+	uint64		default_pgs_mask;
+
 	/* partition descriptors */
 	PartitionDirectory partition_directory pg_node_attr(read_write_ignore);
 
@@ -939,7 +1015,7 @@ typedef struct RelOptInfo
 	Cardinality rows;
 
 	/*
-	 * per-relation planner control flags
+	 * per-relation planner control
 	 */
 	/* keep cheap-startup-cost paths? */
 	bool		consider_startup;
@@ -947,6 +1023,8 @@ typedef struct RelOptInfo
 	bool		consider_param_startup;
 	/* consider parallel paths? */
 	bool		consider_parallel;
+	/* path generation strategy mask */
+	uint64		pgs_mask;
 
 	/*
 	 * default result targetlist for Paths scanning this relation; list of
@@ -3506,6 +3584,7 @@ typedef struct SemiAntiJoinFactors
  * sjinfo is extra info about special joins for selectivity estimation
  * semifactors is as shown above (only valid for SEMI/ANTI/inner_unique joins)
  * param_source_rels are OK targets for parameterization of result paths
+ * pgs_mask is a bitmask of PGS_* constants to limit the join strategy
  */
 typedef struct JoinPathExtraData
 {
@@ -3515,6 +3594,7 @@ typedef struct JoinPathExtraData
 	SpecialJoinInfo *sjinfo;
 	SemiAntiJoinFactors semifactors;
 	Relids		param_source_rels;
+	uint64		pgs_mask;
 } JoinPathExtraData;
 
 /*
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index b523bcda8f3..2d80462bece 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -125,7 +125,7 @@ extern void cost_merge_append(Path *path, PlannerInfo *root,
 							  Cost input_startup_cost, Cost input_total_cost,
 							  double tuples);
 extern void cost_material(Path *path,
-						  int input_disabled_nodes,
+						  bool enabled, int input_disabled_nodes,
 						  Cost input_startup_cost, Cost input_total_cost,
 						  double tuples, int width);
 extern void cost_agg(Path *path, PlannerInfo *root,
@@ -148,7 +148,7 @@ extern void cost_group(Path *path, PlannerInfo *root,
 					   double input_tuples);
 extern void initial_cost_nestloop(PlannerInfo *root,
 								  JoinCostWorkspace *workspace,
-								  JoinType jointype,
+								  JoinType jointype, uint64 enable_mask,
 								  Path *outer_path, Path *inner_path,
 								  JoinPathExtraData *extra);
 extern void final_cost_nestloop(PlannerInfo *root, NestPath *path,
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index dbf4702acc9..123b78cbf11 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -17,6 +17,14 @@
 #include "nodes/bitmapset.h"
 #include "nodes/pathnodes.h"
 
+/* Hook for plugins to get control during joinrel setup */
+typedef void (*joinrel_setup_hook_type) (PlannerInfo *root,
+										 RelOptInfo *joinrel,
+										 RelOptInfo *outer_rel,
+										 RelOptInfo *inner_rel,
+										 SpecialJoinInfo *sjinfo,
+										 List *restrictlist);
+extern PGDLLIMPORT joinrel_setup_hook_type joinrel_setup_hook;
 
 /*
  * prototypes for pathnode.c
@@ -85,7 +93,8 @@ extern GroupResultPath *create_group_result_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 PathTarget *target,
 												 List *havingqual);
-extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath);
+extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath,
+										  bool enabled);
 extern MemoizePath *create_memoize_path(PlannerInfo *root,
 										RelOptInfo *rel,
 										Path *subpath,
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index f6a62df0b43..61c1607f872 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -28,7 +28,14 @@ extern PGDLLIMPORT int min_parallel_table_scan_size;
 extern PGDLLIMPORT int min_parallel_index_scan_size;
 extern PGDLLIMPORT bool enable_group_by_reordering;
 
-/* Hook for plugins to get control in set_rel_pathlist() */
+/* Hooks for plugins to get control in set_rel_pathlist() */
+typedef void (*join_path_setup_hook_type) (PlannerInfo *root,
+										   RelOptInfo *joinrel,
+										   RelOptInfo *outerrel,
+										   RelOptInfo *innerrel,
+										   JoinType jointype,
+										   JoinPathExtraData *extra);
+extern PGDLLIMPORT join_path_setup_hook_type join_path_setup_hook;
 typedef void (*set_rel_pathlist_hook_type) (PlannerInfo *root,
 											RelOptInfo *rel,
 											Index rti,
-- 
2.51.0



  [application/octet-stream] v6-0003-Store-information-about-Append-node-consolidation.patch (27.0K, 4-v6-0003-Store-information-about-Append-node-consolidation.patch)
  download | inline diff:
From 78a29ffba268bb6492478a92854be727a9783938 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:07 -0400
Subject: [PATCH v6 3/5] Store information about Append node consolidation in
 the final plan.

An extension (or core code) might want to reconstruct the planner's
decisions about whether and where to perform partitionwise joins from
the final plan. To do so, it must be possible to find all of the RTIs
of partitioned tables appearing in the plan. But when an AppendPath
or MergeAppendPath pulls up child paths from a subordinate AppendPath
or MergeAppendPath, the RTIs of the subordinate path do not appear
in the final plan, making this kind of reconstruction impossible.

To avoid this, propagate the RTI sets that would have been present
in the 'apprelids' field of the subordinate Append or MergeAppend
nodes that would have been created into the surviving Append or
MergeAppend node, using a new 'child_append_relid_sets' field for
that purpose. The value of this field is a list of Bitmapsets,
because each relation whose append-list was pulled up had its own
set of RTIs: just one, if it was a partitionwise scan, or more than
one, if it was a partitionwise join. Since our goal is to see where
partitionwise joins were done, it is essential to avoid losing the
information about how the RTIs were grouped in the pulled-up
relations.

This commit also updates pg_overexplain so that EXPLAIN (RANGE_TABLE)
will display the saved RTI sets.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 56 +++++++++++
 src/backend/optimizer/path/allpaths.c         | 98 +++++++++++++++----
 src/backend/optimizer/path/joinrels.c         |  2 +-
 src/backend/optimizer/plan/createplan.c       |  2 +
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/prep/prepunion.c        | 11 ++-
 src/backend/optimizer/util/pathnode.c         |  5 +
 src/include/nodes/pathnodes.h                 | 10 ++
 src/include/nodes/plannodes.h                 | 11 +++
 src/include/optimizer/pathnode.h              |  2 +
 11 files changed, 175 insertions(+), 27 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index ca9a23ea61f..a377fb2571d 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -104,6 +104,7 @@ $$);
                Parallel Safe: true
                Plan Node ID: 2
                Append RTIs: 1
+               Child Append RTIs: none
                ->  Seq Scan on brassica vegetables_1
                      Disabled Nodes: 0
                      Parallel Safe: true
@@ -142,7 +143,7 @@ $$);
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 3 4
-(53 rows)
+(54 rows)
 
 -- Test a different output format.
 SELECT explain_filter($$
@@ -197,6 +198,7 @@ $$);
                <extParam>none</extParam>                            +
                <allParam>none</allParam>                            +
                <Append-RTIs>1</Append-RTIs>                         +
+               <Child-Append-RTIs>none</Child-Append-RTIs>          +
                <Subplans-Removed>0</Subplans-Removed>               +
                <Plans>                                              +
                  <Plan>                                             +
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index e54f8cfc332..c28348ea966 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -54,6 +54,8 @@ static void overexplain_alias(const char *qlabel, Alias *alias,
 							  ExplainState *es);
 static void overexplain_bitmapset(const char *qlabel, Bitmapset *bms,
 								  ExplainState *es);
+static void overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+									   ExplainState *es);
 static void overexplain_intlist(const char *qlabel, List *list,
 								ExplainState *es);
 
@@ -232,11 +234,17 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 				overexplain_bitmapset("Append RTIs",
 									  ((Append *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((Append *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_MergeAppend:
 				overexplain_bitmapset("Append RTIs",
 									  ((MergeAppend *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((MergeAppend *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_Result:
 
@@ -815,6 +823,54 @@ overexplain_bitmapset(const char *qlabel, Bitmapset *bms, ExplainState *es)
 	pfree(buf.data);
 }
 
+/*
+ * Emit a text property describing the contents of a list of bitmapsets.
+ * If a bitmapset contains exactly 1 member, we just print an integer;
+ * otherwise, we surround the list of members by parentheses.
+ *
+ * If there are no bitmapsets in the list, we print the word "none".
+ */
+static void
+overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+						   ExplainState *es)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+
+	foreach_node(Bitmapset, bms, bms_list)
+	{
+		if (bms_membership(bms) == BMS_SINGLETON)
+			appendStringInfo(&buf, " %d", bms_singleton_member(bms));
+		else
+		{
+			int			x = -1;
+			bool		first = true;
+
+			appendStringInfoString(&buf, " (");
+			while ((x = bms_next_member(bms, x)) >= 0)
+			{
+				if (first)
+					first = false;
+				else
+					appendStringInfoChar(&buf, ' ');
+				appendStringInfo(&buf, "%d", x);
+			}
+			appendStringInfoChar(&buf, ')');
+		}
+	}
+
+	if (buf.len == 0)
+	{
+		ExplainPropertyText(qlabel, "none", es);
+		return;
+	}
+
+	Assert(buf.data[0] == ' ');
+	ExplainPropertyText(qlabel, buf.data + 1, es);
+	pfree(buf.data);
+}
+
 /*
  * Emit a text property describing the contents of a list of integers, OIDs,
  * or XIDs -- either a space-separated list of integer members, or the word
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 4c43fd0b19b..928b8d84ad8 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -128,8 +128,10 @@ static Path *get_cheapest_parameterized_child_path(PlannerInfo *root,
 												   Relids required_outer);
 static void accumulate_append_subpath(Path *path,
 									  List **subpaths,
-									  List **special_subpaths);
-static Path *get_singleton_append_subpath(Path *path);
+									  List **special_subpaths,
+									  List **child_append_relid_sets);
+static Path *get_singleton_append_subpath(Path *path,
+										  List **child_append_relid_sets);
 static void set_dummy_rel_pathlist(RelOptInfo *rel);
 static void set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 								  Index rti, RangeTblEntry *rte);
@@ -1406,11 +1408,15 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 {
 	List	   *subpaths = NIL;
 	bool		subpaths_valid = true;
+	List	   *subpath_cars = NIL;
 	List	   *startup_subpaths = NIL;
 	bool		startup_subpaths_valid = true;
+	List	   *startup_subpath_cars = NIL;
 	List	   *partial_subpaths = NIL;
+	List	   *partial_subpath_cars = NIL;
 	List	   *pa_partial_subpaths = NIL;
 	List	   *pa_nonpartial_subpaths = NIL;
+	List	   *pa_subpath_cars = NIL;
 	bool		partial_subpaths_valid = true;
 	bool		pa_subpaths_valid;
 	List	   *all_child_pathkeys = NIL;
@@ -1443,7 +1449,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		if (childrel->pathlist != NIL &&
 			childrel->cheapest_total_path->param_info == NULL)
 			accumulate_append_subpath(childrel->cheapest_total_path,
-									  &subpaths, NULL);
+									  &subpaths, NULL, &subpath_cars);
 		else
 			subpaths_valid = false;
 
@@ -1472,7 +1478,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 			Assert(cheapest_path->param_info == NULL);
 			accumulate_append_subpath(cheapest_path,
 									  &startup_subpaths,
-									  NULL);
+									  NULL,
+									  &startup_subpath_cars);
 		}
 		else
 			startup_subpaths_valid = false;
@@ -1483,7 +1490,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		{
 			cheapest_partial_path = linitial(childrel->partial_pathlist);
 			accumulate_append_subpath(cheapest_partial_path,
-									  &partial_subpaths, NULL);
+									  &partial_subpaths, NULL,
+									  &partial_subpath_cars);
 		}
 		else
 			partial_subpaths_valid = false;
@@ -1512,7 +1520,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				Assert(cheapest_partial_path != NULL);
 				accumulate_append_subpath(cheapest_partial_path,
 										  &pa_partial_subpaths,
-										  &pa_nonpartial_subpaths);
+										  &pa_nonpartial_subpaths,
+										  &pa_subpath_cars);
 			}
 			else
 			{
@@ -1531,7 +1540,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				 */
 				accumulate_append_subpath(nppath,
 										  &pa_nonpartial_subpaths,
-										  NULL);
+										  NULL,
+										  &pa_subpath_cars);
 			}
 		}
 
@@ -1606,14 +1616,16 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 	 * if we have zero or one live subpath due to constraint exclusion.)
 	 */
 	if (subpaths_valid)
-		add_path(rel, (Path *) create_append_path(root, rel, subpaths, NIL,
+		add_path(rel, (Path *) create_append_path(root, rel, subpaths,
+												  NIL, subpath_cars,
 												  NIL, NULL, 0, false,
 												  -1));
 
 	/* build an AppendPath for the cheap startup paths, if valid */
 	if (startup_subpaths_valid)
 		add_path(rel, (Path *) create_append_path(root, rel, startup_subpaths,
-												  NIL, NIL, NULL, 0, false, -1));
+												  NIL, startup_subpath_cars,
+												  NIL, NULL, 0, false, -1));
 
 	/*
 	 * Consider an append of unordered, unparameterized partial paths.  Make
@@ -1654,6 +1666,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Generate a partial append path. */
 		appendpath = create_append_path(root, rel, NIL, partial_subpaths,
+										partial_subpath_cars,
 										NIL, NULL, parallel_workers,
 										enable_parallel_append,
 										-1);
@@ -1704,6 +1717,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		appendpath = create_append_path(root, rel, pa_nonpartial_subpaths,
 										pa_partial_subpaths,
+										pa_subpath_cars,
 										NIL, NULL, parallel_workers, true,
 										partial_rows);
 		add_partial_path(rel, (Path *) appendpath);
@@ -1737,6 +1751,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Select the child paths for an Append with this parameterization */
 		subpaths = NIL;
+		subpath_cars = NIL;
 		subpaths_valid = true;
 		foreach(lcr, live_childrels)
 		{
@@ -1759,12 +1774,13 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				subpaths_valid = false;
 				break;
 			}
-			accumulate_append_subpath(subpath, &subpaths, NULL);
+			accumulate_append_subpath(subpath, &subpaths, NULL,
+									  &subpath_cars);
 		}
 
 		if (subpaths_valid)
 			add_path(rel, (Path *)
-					 create_append_path(root, rel, subpaths, NIL,
+					 create_append_path(root, rel, subpaths, NIL, subpath_cars,
 										NIL, required_outer, 0, false,
 										-1));
 	}
@@ -1791,6 +1807,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				continue;
 
 			appendpath = create_append_path(root, rel, NIL, list_make1(path),
+											list_make1(rel->relids),
 											NIL, NULL,
 											path->parallel_workers, true,
 											partial_rows);
@@ -1874,8 +1891,11 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 	{
 		List	   *pathkeys = (List *) lfirst(lcp);
 		List	   *startup_subpaths = NIL;
+		List	   *startup_subpath_cars = NIL;
 		List	   *total_subpaths = NIL;
+		List	   *total_subpath_cars = NIL;
 		List	   *fractional_subpaths = NIL;
+		List	   *fractional_subpath_cars = NIL;
 		bool		startup_neq_total = false;
 		bool		fraction_neq_total = false;
 		bool		match_partition_order;
@@ -2038,16 +2058,23 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * just a single subpath (and hence aren't doing anything
 				 * useful).
 				 */
-				cheapest_startup = get_singleton_append_subpath(cheapest_startup);
-				cheapest_total = get_singleton_append_subpath(cheapest_total);
+				cheapest_startup =
+					get_singleton_append_subpath(cheapest_startup,
+												 &startup_subpath_cars);
+				cheapest_total =
+					get_singleton_append_subpath(cheapest_total,
+												 &total_subpath_cars);
 
 				startup_subpaths = lappend(startup_subpaths, cheapest_startup);
 				total_subpaths = lappend(total_subpaths, cheapest_total);
 
 				if (cheapest_fractional)
 				{
-					cheapest_fractional = get_singleton_append_subpath(cheapest_fractional);
-					fractional_subpaths = lappend(fractional_subpaths, cheapest_fractional);
+					cheapest_fractional =
+						get_singleton_append_subpath(cheapest_fractional,
+													 &fractional_subpath_cars);
+					fractional_subpaths =
+						lappend(fractional_subpaths, cheapest_fractional);
 				}
 			}
 			else
@@ -2057,13 +2084,16 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * child paths for the MergeAppend.
 				 */
 				accumulate_append_subpath(cheapest_startup,
-										  &startup_subpaths, NULL);
+										  &startup_subpaths, NULL,
+										  &startup_subpath_cars);
 				accumulate_append_subpath(cheapest_total,
-										  &total_subpaths, NULL);
+										  &total_subpaths, NULL,
+										  &total_subpath_cars);
 
 				if (cheapest_fractional)
 					accumulate_append_subpath(cheapest_fractional,
-											  &fractional_subpaths, NULL);
+											  &fractional_subpaths, NULL,
+											  &fractional_subpath_cars);
 			}
 		}
 
@@ -2075,6 +2105,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 													  rel,
 													  startup_subpaths,
 													  NIL,
+													  startup_subpath_cars,
 													  pathkeys,
 													  NULL,
 													  0,
@@ -2085,6 +2116,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  total_subpaths,
 														  NIL,
+														  total_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2096,6 +2128,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  fractional_subpaths,
 														  NIL,
+														  fractional_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2108,12 +2141,14 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 			add_path(rel, (Path *) create_merge_append_path(root,
 															rel,
 															startup_subpaths,
+															startup_subpath_cars,
 															pathkeys,
 															NULL));
 			if (startup_neq_total)
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																total_subpaths,
+																total_subpath_cars,
 																pathkeys,
 																NULL));
 
@@ -2121,6 +2156,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																fractional_subpaths,
+																fractional_subpath_cars,
 																pathkeys,
 																NULL));
 		}
@@ -2223,7 +2259,8 @@ get_cheapest_parameterized_child_path(PlannerInfo *root, RelOptInfo *rel,
  * paths).
  */
 static void
-accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
+accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
+						  List **child_append_relid_sets)
 {
 	if (IsA(path, AppendPath))
 	{
@@ -2232,6 +2269,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		if (!apath->path.parallel_aware || apath->first_partial_path == 0)
 		{
 			*subpaths = list_concat(*subpaths, apath->subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 		else if (special_subpaths != NULL)
@@ -2246,6 +2285,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 												  apath->first_partial_path);
 			*special_subpaths = list_concat(*special_subpaths,
 											new_special_subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 	}
@@ -2254,6 +2295,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		*subpaths = list_concat(*subpaths, mpath->subpaths);
+		*child_append_relid_sets =
+			lappend(*child_append_relid_sets, path->parent->relids);
 		return;
 	}
 
@@ -2265,10 +2308,15 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
  *		Returns the single subpath of an Append/MergeAppend, or just
  *		return 'path' if it's not a single sub-path Append/MergeAppend.
  *
+ * As a side effect, whenever we return a single subpath rather than the
+ * original path, add the relid set for the original path to
+ * child_append_relid_sets, so that those relids don't entirely disappear
+ * from the final plan.
+ *
  * Note: 'path' must not be a parallel-aware path.
  */
 static Path *
-get_singleton_append_subpath(Path *path)
+get_singleton_append_subpath(Path *path, List **child_append_relid_sets)
 {
 	Assert(!path->parallel_aware);
 
@@ -2277,14 +2325,22 @@ get_singleton_append_subpath(Path *path)
 		AppendPath *apath = (AppendPath *) path;
 
 		if (list_length(apath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(apath->subpaths);
+		}
 	}
 	else if (IsA(path, MergeAppendPath))
 	{
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		if (list_length(mpath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(mpath->subpaths);
+		}
 	}
 
 	return path;
@@ -2313,7 +2369,7 @@ set_dummy_rel_pathlist(RelOptInfo *rel)
 	rel->partial_pathlist = NIL;
 
 	/* Set up the dummy path */
-	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
+	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL, NIL,
 											  NIL, rel->lateral_relids,
 											  0, false, -1));
 
diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 8827b9a5245..76478389653 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -1530,7 +1530,7 @@ mark_dummy_rel(RelOptInfo *rel)
 
 	/* Set up the dummy path */
 	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
-											  NIL, rel->lateral_relids,
+											  NIL, NIL, rel->lateral_relids,
 											  0, false, -1));
 
 	/* Set or update cheapest_total_path and related fields */
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index bc417f93840..e3f27a586ca 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -1265,6 +1265,7 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags)
 	plan->plan.lefttree = NULL;
 	plan->plan.righttree = NULL;
 	plan->apprelids = rel->relids;
+	plan->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	if (pathkeys != NIL)
 	{
@@ -1477,6 +1478,7 @@ create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path,
 	plan->lefttree = NULL;
 	plan->righttree = NULL;
 	node->apprelids = rel->relids;
+	node->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	/*
 	 * Compute sort column info, and adjust MergeAppend's tlist as needed.
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index df9d03d5492..94e1ac96ed9 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4027,6 +4027,7 @@ create_degenerate_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
 							   paths,
 							   NIL,
 							   NIL,
+							   NIL,
 							   NULL,
 							   0,
 							   false,
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index a01b02f3a7b..12d0c821ed7 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -843,7 +843,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 	 * union child.
 	 */
 	apath = (Path *) create_append_path(root, result_rel, cheapest_pathlist,
-										NIL, NIL, NULL, 0, false, -1);
+										NIL, NIL, NIL, NULL, 0, false, -1);
 
 	/*
 	 * Estimate number of groups.  For now we just assume the output is unique
@@ -889,7 +889,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 
 		papath = (Path *)
 			create_append_path(root, result_rel, NIL, partial_pathlist,
-							   NIL, NULL, parallel_workers,
+							   NIL, NIL, NULL, parallel_workers,
 							   enable_parallel_append, -1);
 		gpath = (Path *)
 			create_gather_path(root, result_rel, papath,
@@ -1018,6 +1018,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 			path = (Path *) create_merge_append_path(root,
 													 result_rel,
 													 ordered_pathlist,
+													 NIL,
 													 union_pathkeys,
 													 NULL);
 
@@ -1224,8 +1225,10 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root,
 				 * between the set op targetlist and the targetlist of the
 				 * left input.  The Append will be removed in setrefs.c.
 				 */
-				apath = (Path *) create_append_path(root, result_rel, list_make1(lpath),
-													NIL, NIL, NULL, 0, false, -1);
+				apath = (Path *) create_append_path(root, result_rel,
+													list_make1(lpath),
+													NIL, NIL, NIL, NULL, 0,
+													false, -1);
 
 				add_path(result_rel, apath);
 
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index b6be4ddbd01..33ce34f0088 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1301,6 +1301,7 @@ AppendPath *
 create_append_path(PlannerInfo *root,
 				   RelOptInfo *rel,
 				   List *subpaths, List *partial_subpaths,
+				   List *child_append_relid_sets,
 				   List *pathkeys, Relids required_outer,
 				   int parallel_workers, bool parallel_aware,
 				   double rows)
@@ -1310,6 +1311,7 @@ create_append_path(PlannerInfo *root,
 
 	Assert(!parallel_aware || parallel_workers > 0);
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_Append;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -1472,6 +1474,7 @@ MergeAppendPath *
 create_merge_append_path(PlannerInfo *root,
 						 RelOptInfo *rel,
 						 List *subpaths,
+						 List *child_append_relid_sets,
 						 List *pathkeys,
 						 Relids required_outer)
 {
@@ -1487,6 +1490,7 @@ create_merge_append_path(PlannerInfo *root,
 	 */
 	Assert(bms_is_empty(rel->lateral_relids) && bms_is_empty(required_outer));
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_MergeAppend;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -3951,6 +3955,7 @@ reparameterize_path(PlannerInfo *root, Path *path,
 				}
 				return (Path *)
 					create_append_path(root, rel, childpaths, partialpaths,
+									   apath->child_append_relid_sets,
 									   apath->path.pathkeys, required_outer,
 									   apath->path.parallel_workers,
 									   apath->path.parallel_aware,
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 42c146d802a..ce623da71cc 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2172,6 +2172,12 @@ typedef struct CustomPath
  * For partial Append, 'subpaths' contains non-partial subpaths followed by
  * partial subpaths.
  *
+ * Whenever accumulate_append_subpath() allows us to consolidate multiple
+ * levels of Append paths down to one, we store the RTI sets for the omitted
+ * paths in child_append_relid_sets. This is not necessary for planning or
+ * execution; we do it for the benefit of code that wants to inspect the
+ * final plan and understand how it came to be.
+ *
  * Note: it is possible for "subpaths" to contain only one, or even no,
  * elements.  These cases are optimized during create_append_plan.
  * In particular, an AppendPath with no subpaths is a "dummy" path that
@@ -2187,6 +2193,7 @@ typedef struct AppendPath
 	/* Index of first partial path in subpaths; list_length(subpaths) if none */
 	int			first_partial_path;
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } AppendPath;
 
 #define IS_DUMMY_APPEND(p) \
@@ -2203,12 +2210,15 @@ extern bool is_dummy_rel(RelOptInfo *rel);
 /*
  * MergeAppendPath represents a MergeAppend plan, ie, the merging of sorted
  * results from several member plans to produce similarly-sorted output.
+ *
+ * child_append_relid_sets has the same meaning here as for AppendPath.
  */
 typedef struct MergeAppendPath
 {
 	Path		path;
 	List	   *subpaths;		/* list of component Paths */
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } MergeAppendPath;
 
 /*
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 5d0520d5e58..045b7ee84a7 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -394,9 +394,16 @@ struct PartitionPruneInfo;		/* forward reference to struct below */
 typedef struct Append
 {
 	Plan		plan;
+
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
+
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *appendplans;
+
 	/* # of asynchronous plans */
 	int			nasyncplans;
 
@@ -426,6 +433,10 @@ typedef struct MergeAppend
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
 
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *mergeplans;
 
 	/* these fields are just like the sort-key info in struct Sort: */
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 6b010f0b1a5..dbf4702acc9 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -71,12 +71,14 @@ extern TidRangePath *create_tidrangescan_path(PlannerInfo *root,
 											  int parallel_workers);
 extern AppendPath *create_append_path(PlannerInfo *root, RelOptInfo *rel,
 									  List *subpaths, List *partial_subpaths,
+									  List *child_append_relid_sets,
 									  List *pathkeys, Relids required_outer,
 									  int parallel_workers, bool parallel_aware,
 									  double rows);
 extern MergeAppendPath *create_merge_append_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 List *subpaths,
+												 List *child_append_relid_sets,
 												 List *pathkeys,
 												 Relids required_outer);
 extern GroupResultPath *create_group_result_path(PlannerInfo *root,
-- 
2.51.0



  [application/octet-stream] v6-0001-Store-information-about-range-table-flattening-in.patch (7.9K, 5-v6-0001-Store-information-about-range-table-flattening-in.patch)
  download | inline diff:
From 9c52d8990f087189137e945bdc6dfde062a295ac Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 12:00:18 -0400
Subject: [PATCH v6 1/5] Store information about range-table flattening in the
 final plan.

Suppose that we're currently planning a query and, when that same
query was previously planned and executed, we learned something about
how a certain table within that query should be planned. We want to
take note when that same table is being planned during the current
planning cycle, but this is difficult to do, because the RTI of the
table from the previous plan won't necessarily be equal to the RTI
that we see during the current planning cycle. This is because each
subquery has a separate range table during planning, but these are
flattened into one range table when constructing the final plan,
changing RTIs.

Commit 8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0 allows us to match up
subqueries seen in the previous planning cycles with the subqueries
currently being planned just by comparing textual names, but that's
not quite enough to let us deduce anything about individual tables,
because we don't know where each subquery's range table appears in
the final, flattened range table.

To fix that, store a list of SubPlanRTInfo objects in the final
planned statement, each including the name of the subplan, the offset
at which it begins in the flattened range table, and whether or not
it was a dummy subplan -- if it was, some RTIs may have been dropped
from the final range table, but also there's no need to control how
a dummy subquery gets planned. The toplevel subquery has no name and
always begins at rtoffset 0, so we make no entry for it.

This commit teaches pg_overexplain's RANGE_TABLE option to make use
of this new data to display the subquery name for each range table
entry.

NOTE TO REVIEWERS: If there's a clean way to make pg_overexplain display
this information without the new infrastructure provided by this patch,
then this patch is unnecessary. I thought there would be a way to do
that, but I couldn't figure anything out: there seems to be nothing that
records in the final PlannedStmt where subquery's range table ends and
the next one begins. In practice, one could usually figure it out by
matching up tables by relation OID, but that's neither clean nor
theoretically sound.
---
 contrib/pg_overexplain/pg_overexplain.c | 36 +++++++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    |  1 +
 src/backend/optimizer/plan/setrefs.c    | 20 ++++++++++++++
 src/include/nodes/pathnodes.h           |  3 +++
 src/include/nodes/plannodes.h           | 17 ++++++++++++
 src/tools/pgindent/typedefs.list        |  1 +
 6 files changed, 78 insertions(+)

diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index fcdc17012da..1c4c796adb2 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -395,6 +395,8 @@ static void
 overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 {
 	Index		rti;
+	ListCell   *lc_subrtinfo = list_head(plannedstmt->subrtinfos);
+	SubPlanRTInfo *rtinfo = NULL;
 
 	/* Open group, one entry per RangeTblEntry */
 	ExplainOpenGroup("Range Table", "Range Table", false, es);
@@ -405,6 +407,18 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 		RangeTblEntry *rte = rt_fetch(rti, plannedstmt->rtable);
 		char	   *kind = NULL;
 		char	   *relkind;
+		SubPlanRTInfo *next_rtinfo;
+
+		/* Advance to next SubRTInfo, if it's time. */
+		if (lc_subrtinfo != NULL)
+		{
+			next_rtinfo = lfirst(lc_subrtinfo);
+			if (rti > next_rtinfo->rtoffset)
+			{
+				rtinfo = next_rtinfo;
+				lc_subrtinfo = lnext(plannedstmt->subrtinfos, lc_subrtinfo);
+			}
+		}
 
 		/* NULL entries are possible; skip them */
 		if (rte == NULL)
@@ -469,6 +483,28 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 			ExplainPropertyBool("In From Clause", rte->inFromCl, es);
 		}
 
+		/*
+		 * Indicate which subplan is the origin of which RTE. Note dummy
+		 * subplans. Here again, we crunch more onto one line in text format.
+		 */
+		if (rtinfo != NULL)
+		{
+			if (es->format == EXPLAIN_FORMAT_TEXT)
+			{
+				if (!rtinfo->dummy)
+					ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				else
+					ExplainPropertyText("Subplan",
+										psprintf("%s (dummy)",
+												 rtinfo->plan_name), es);
+			}
+			else
+			{
+				ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				ExplainPropertyBool("Subplan Is Dummy", rtinfo->dummy, es);
+			}
+		}
+
 		/* rte->alias is optional; rte->eref is requested */
 		if (rte->alias != NULL)
 			overexplain_alias("Alias", rte->alias, es);
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 8b22c30559b..31dcbdf3422 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -607,6 +607,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->unprunableRelids = bms_difference(glob->allRelids,
 											  glob->prunableRelids);
 	result->permInfos = glob->finalrteperminfos;
+	result->subrtinfos = glob->subrtinfos;
 	result->resultRelations = glob->resultRelations;
 	result->appendRelations = glob->appendRelations;
 	result->subplans = glob->subplans;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index cd7ea1e6b58..edcd4aaa53e 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -399,6 +399,26 @@ add_rtes_to_flat_rtable(PlannerInfo *root, bool recursing)
 	Index		rti;
 	ListCell   *lc;
 
+	/*
+	 * Record enough information to make it possible for code that looks at
+	 * the final range table to understand how it was constructed. (If
+	 * finalrtable is still NIL, then this is the very topmost PlannerInfo,
+	 * which will always have plan_name == NULL and rtoffset == 0; we omit the
+	 * degenerate list entry.)
+	 */
+	if (root->glob->finalrtable != NIL)
+	{
+		SubPlanRTInfo *rtinfo = makeNode(SubPlanRTInfo);
+
+		rtinfo->plan_name = root->plan_name;
+		rtinfo->rtoffset = list_length(root->glob->finalrtable);
+
+		/* When recursing = true, it's an unplanned or dummy subquery. */
+		rtinfo->dummy = recursing;
+
+		root->glob->subrtinfos = lappend(root->glob->subrtinfos, rtinfo);
+	}
+
 	/*
 	 * Add the query's own RTEs to the flattened rangetable.
 	 *
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 46a8655621d..3782bc64075 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -135,6 +135,9 @@ typedef struct PlannerGlobal
 	/* "flat" list of RTEPermissionInfos */
 	List	   *finalrteperminfos;
 
+	/* list of SubPlanRTInfo nodes */
+	List	   *subrtinfos;
+
 	/* "flat" list of PlanRowMarks */
 	List	   *finalrowmarks;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index c4393a94321..1526dd2ec6b 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -131,6 +131,9 @@ typedef struct PlannedStmt
 	 */
 	List	   *subplans;
 
+	/* a list of SubPlanRTInfo objects */
+	List	   *subrtinfos;
+
 	/* indices of subplans that require REWIND */
 	Bitmapset  *rewindPlanIDs;
 
@@ -1821,4 +1824,18 @@ typedef enum MonotonicFunction
 	MONOTONICFUNC_BOTH = MONOTONICFUNC_INCREASING | MONOTONICFUNC_DECREASING,
 } MonotonicFunction;
 
+/*
+ * SubPlanRTInfo
+ *
+ * Information about which range table entries came from which subquery
+ * planning cycles.
+ */
+typedef struct SubPlanRTInfo
+{
+	NodeTag		type;
+	const char *plan_name;
+	Index		rtoffset;
+	bool		dummy;
+} SubPlanRTInfo;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9dd65b10254..025bba87834 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2902,6 +2902,7 @@ SubLink
 SubLinkType
 SubOpts
 SubPlan
+SubPlanRTInfo
 SubPlanState
 SubRelInfo
 SubRemoveRels
-- 
2.51.0



  [application/octet-stream] v6-0005-WIP-Add-pg_plan_advice-contrib-module.patch (375.9K, 6-v6-0005-WIP-Add-pg_plan_advice-contrib-module.patch)
  download | inline diff:
From 1457acfb19eb22e8b4292f633b695354e7d84b10 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 4 Nov 2025 14:45:31 -0500
Subject: [PATCH v6 5/5] WIP: Add pg_plan_advice contrib module.

Provide a facility that (1) can be used to stabilize certain plan choices
so that the planner cannot reverse course without authorization and
(2) can be used by knowledgeable users to insist on plan choices contrary
to what the planner believes best. In both cases, terrible outcomes are
possible: users should think twice and perhaps three times before
constraining the planner's ability to do as it thinks best; nevertheless,
there are problems that are much more easily solved with these facilities
than without them.

We take the approach of analyzing a finished plan to produce textual
output, which we call "plan advice", that describes key decisions made
during plan; if that plan advice is provided during future planning
cycles, it will force those key decisions to be made in the same way.
Not all planner decisions can be controlled using advice; for example,
decisions about how to perform aggregation are currently out of scope,
as is choice of sort order. Plan advice can also be edited by the user,
or even written from scratch in simple cases, making it possible to
generate outcomes that the planner would not have produced. Partial
advice can be provided to control some planner outcomes but not others.

Currently, plan advice is focused only on specific outcomes, such as
the choice to use a sequential scan for a particular relation, and not
on estimates that might contribute to those outcomes, such as a
possibly-incorrect selectivity estimate. While it would be useful to
users to be able to provide plan advice that affects selectivity
estimates or other aspects of costing, that is out of scope for this
commit.

For more details, see contrib/pg_plan_advice/README.

NOTE: This code is just a proof of concept. A bunch of things don't
work and a lot of the code needs cleanup. It has no SGML documentation
and not enough test cases, and some of the existing test cases don't
do as we would hope. Known problems are called out by XXX.
---
 contrib/Makefile                              |    1 +
 contrib/meson.build                           |    1 +
 contrib/pg_plan_advice/.gitignore             |    3 +
 contrib/pg_plan_advice/Makefile               |   50 +
 contrib/pg_plan_advice/README                 |  275 +++
 contrib/pg_plan_advice/expected/gather.out    |  320 +++
 .../pg_plan_advice/expected/join_order.out    |  292 +++
 .../pg_plan_advice/expected/join_strategy.out |  297 +++
 .../expected/local_collector.out              |   65 +
 .../pg_plan_advice/expected/partitionwise.out |  243 +++
 contrib/pg_plan_advice/expected/scan.out      |  757 ++++++++
 contrib/pg_plan_advice/expected/syntax.out    |   59 +
 contrib/pg_plan_advice/meson.build            |   70 +
 .../pg_plan_advice/pg_plan_advice--1.0.sql    |   42 +
 contrib/pg_plan_advice/pg_plan_advice.c       |  454 +++++
 contrib/pg_plan_advice/pg_plan_advice.control |    5 +
 contrib/pg_plan_advice/pg_plan_advice.h       |   37 +
 contrib/pg_plan_advice/pgpa_ast.c             |  392 ++++
 contrib/pg_plan_advice/pgpa_ast.h             |  204 ++
 contrib/pg_plan_advice/pgpa_collector.c       |  637 ++++++
 contrib/pg_plan_advice/pgpa_collector.h       |   18 +
 contrib/pg_plan_advice/pgpa_identifier.c      |  476 +++++
 contrib/pg_plan_advice/pgpa_identifier.h      |   52 +
 contrib/pg_plan_advice/pgpa_join.c            |  615 ++++++
 contrib/pg_plan_advice/pgpa_join.h            |  105 +
 contrib/pg_plan_advice/pgpa_output.c          |  628 ++++++
 contrib/pg_plan_advice/pgpa_output.h          |   22 +
 contrib/pg_plan_advice/pgpa_parser.y          |  337 ++++
 contrib/pg_plan_advice/pgpa_planner.c         | 1707 +++++++++++++++++
 contrib/pg_plan_advice/pgpa_planner.h         |   17 +
 contrib/pg_plan_advice/pgpa_scan.c            |  284 +++
 contrib/pg_plan_advice/pgpa_scan.h            |   85 +
 contrib/pg_plan_advice/pgpa_scanner.l         |  299 +++
 contrib/pg_plan_advice/pgpa_trove.c           |  490 +++++
 contrib/pg_plan_advice/pgpa_trove.h           |  113 ++
 contrib/pg_plan_advice/pgpa_walker.c          |  890 +++++++++
 contrib/pg_plan_advice/pgpa_walker.h          |  122 ++
 contrib/pg_plan_advice/sql/gather.sql         |   76 +
 contrib/pg_plan_advice/sql/join_order.sql     |   96 +
 contrib/pg_plan_advice/sql/join_strategy.sql  |   76 +
 .../pg_plan_advice/sql/local_collector.sql    |   41 +
 contrib/pg_plan_advice/sql/partitionwise.sql  |   78 +
 contrib/pg_plan_advice/sql/scan.sql           |  195 ++
 contrib/pg_plan_advice/sql/syntax.sql         |   42 +
 contrib/pg_plan_advice/t/001_regress.pl       |  147 ++
 src/tools/pgindent/typedefs.list              |   37 +
 46 files changed, 11252 insertions(+)
 create mode 100644 contrib/pg_plan_advice/.gitignore
 create mode 100644 contrib/pg_plan_advice/Makefile
 create mode 100644 contrib/pg_plan_advice/README
 create mode 100644 contrib/pg_plan_advice/expected/gather.out
 create mode 100644 contrib/pg_plan_advice/expected/join_order.out
 create mode 100644 contrib/pg_plan_advice/expected/join_strategy.out
 create mode 100644 contrib/pg_plan_advice/expected/local_collector.out
 create mode 100644 contrib/pg_plan_advice/expected/partitionwise.out
 create mode 100644 contrib/pg_plan_advice/expected/scan.out
 create mode 100644 contrib/pg_plan_advice/expected/syntax.out
 create mode 100644 contrib/pg_plan_advice/meson.build
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice--1.0.sql
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.c
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.control
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.h
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.c
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.h
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.c
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.h
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.c
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.h
 create mode 100644 contrib/pg_plan_advice/pgpa_join.c
 create mode 100644 contrib/pg_plan_advice/pgpa_join.h
 create mode 100644 contrib/pg_plan_advice/pgpa_output.c
 create mode 100644 contrib/pg_plan_advice/pgpa_output.h
 create mode 100644 contrib/pg_plan_advice/pgpa_parser.y
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.c
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.c
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scanner.l
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.c
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.h
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.c
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.h
 create mode 100644 contrib/pg_plan_advice/sql/gather.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_order.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_strategy.sql
 create mode 100644 contrib/pg_plan_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_plan_advice/sql/partitionwise.sql
 create mode 100644 contrib/pg_plan_advice/sql/scan.sql
 create mode 100644 contrib/pg_plan_advice/sql/syntax.sql
 create mode 100644 contrib/pg_plan_advice/t/001_regress.pl

diff --git a/contrib/Makefile b/contrib/Makefile
index 2f0a88d3f77..dd04c20acd2 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -34,6 +34,7 @@ SUBDIRS = \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
+		pg_plan_advice \
 		pg_prewarm	\
 		pg_stat_statements \
 		pg_surgery	\
diff --git a/contrib/meson.build b/contrib/meson.build
index ed30ee7d639..cb718dbdac0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -48,6 +48,7 @@ subdir('pgcrypto')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
+subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
 subdir('pg_stat_statements')
diff --git a/contrib/pg_plan_advice/.gitignore b/contrib/pg_plan_advice/.gitignore
new file mode 100644
index 00000000000..19a14253019
--- /dev/null
+++ b/contrib/pg_plan_advice/.gitignore
@@ -0,0 +1,3 @@
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
new file mode 100644
index 00000000000..1d4c559aed8
--- /dev/null
+++ b/contrib/pg_plan_advice/Makefile
@@ -0,0 +1,50 @@
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_plan_advice.o \
+	pgpa_ast.o \
+	pgpa_collector.o \
+	pgpa_identifier.o \
+	pgpa_join.o \
+	pgpa_output.o \
+	pgpa_parser.o \
+	pgpa_planner.o \
+	pgpa_scan.o \
+	pgpa_scanner.o \
+	pgpa_trove.o \
+	pgpa_walker.o
+
+EXTENSION = pg_plan_advice
+DATA = pg_plan_advice--1.0.sql
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise scan
+TAP_TESTS = 1
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+# required for 001_regress.pl
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+	touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
new file mode 100644
index 00000000000..4590cd03ce5
--- /dev/null
+++ b/contrib/pg_plan_advice/README
@@ -0,0 +1,275 @@
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planing
+cycle, each piece of advice should be guaranteed to apply to the exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should succesfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it is appears to be good advice or bad advice.  If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+   e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously.  Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables.  When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by curly brances, e.g. SOME_SORT_OF_SCAN(foo (bar baz)) would mean that the
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+Bitmap heap scans allow for a more complicated index specification. For
+example, BITMAP_HEAP_SCAN(foo &&(foo_a_idx ||(foo_b_idx foo_c_idx))) says
+that foo should be scanned using a BitmapHeapScan over a BitmapAnd between
+foo_a_idx and the result of a BitmapOr between foo_b_idx and foo_c_idx.
+
+XXX: Currently, BITMAP_HEAP_SCAN does not enforce the index specification,
+because the available hooks are insufficient to do so. It's possible that
+this should be changed to exclude the index specification altogether and
+simply insist that some sort of bitmap heap scan is used; alternatively,
+we need better hooks.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ORDER() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructured. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Strategy Advice
+====================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+strategy advice; no join strategy advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join strategy advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the planner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join strategy advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join strategy advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join strategy advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+XXX: Currently, SEMIJOIN_NON_UNIQUE() advice is emitted in some situations
+where the SEMIJOIN_UNIQUE() approach was determined to be non-viable; ideally,
+we should avoid that.
+
+XXX: Right semijoins haven't been properly thought through. The associated
+code probably just doesn't work.
+
+XXX: Semijoin uniqueness advice has no automated tests and need substantially
+more manual testing.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advise can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constraint the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the outer
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constraint won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
new file mode 100644
index 00000000000..d0224a2aee7
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -0,0 +1,320 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Sort Key: f.dim_id
+   ->  Gather
+         Workers Planned: 1
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(f d)
+(20 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(d)
+   NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(d)
+   NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   NO_GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Disabled: true
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(14 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
new file mode 100644
index 00000000000..e87652370c3
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -0,0 +1,292 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   HASH_JOIN(d1 d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (d1.id = f.dim1_id)
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Hash
+               ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 f d2)
+   HASH_JOIN(f d2)
+   SEQ_SCAN(d1 f d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN(d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Disabled: true
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_PLAIN(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
new file mode 100644
index 00000000000..71ee26a337a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -0,0 +1,297 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Disabled: true
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Materialize
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MATERIALIZE(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Memoize
+         Cache Key: f.dim_id
+         Cache Mode: logical
+         ->  Index Scan using join_dim_pkey on join_dim d
+               Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MEMOIZE(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN              
+-------------------------------------
+ Hash Join
+   Hash Cond: (d.id = f.dim_id)
+   ->  Seq Scan on join_dim d
+   ->  Hash
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   HASH_JOIN(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Materialize
+         ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_MATERIALIZE(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_dim d
+   ->  Materialize
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MATERIALIZE(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Memoize
+         Cache Key: d.id
+         Cache Mode: logical
+         ->  Index Scan using join_fact_dim_id on join_fact f
+               Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MEMOIZE(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+         Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_PLAIN(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(13 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/local_collector.out b/contrib/pg_plan_advice/expected/local_collector.out
new file mode 100644
index 00000000000..56f554bf239
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/local_collector.out
@@ -0,0 +1,65 @@
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
new file mode 100644
index 00000000000..df0f05531d5
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -0,0 +1,243 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt2.id)
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1b pt1_2
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1c pt1_3
+               Filter: (val1 = 1)
+   ->  Hash
+         ->  Hash Join
+               Hash Cond: (pt2.id = pt3.id)
+               ->  Append
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+               ->  Hash
+                     ->  Append
+                           ->  Seq Scan on pt3a pt3_1
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3b pt3_2
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3c pt3_3
+                                 Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE(pt1) /* matched */
+   PARTITIONWISE(pt2) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 (pt2 pt3))
+   HASH_JOIN(pt3 pt3)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE(pt1 pt2 pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(40 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt3.id)
+   ->  Append
+         ->  Hash Join
+               Hash Cond: (pt1_1.id = pt2_1.id)
+               ->  Seq Scan on pt1a pt1_1
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_2.id = pt2_2.id)
+               ->  Seq Scan on pt1b pt1_2
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_3.id = pt2_3.id)
+               ->  Seq Scan on pt1c pt1_3
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+   ->  Hash
+         ->  Append
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3b pt3_2
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3c pt3_3
+                     Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+   JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+   JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+   JOIN_ORDER({pt1 pt2} pt3)
+   HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+   SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+    pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE((pt1 pt2) pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+         ->  Seq Scan on pt1b pt1_2
+         ->  Seq Scan on pt1c pt1_3
+   ->  Append
+         ->  Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+               Index Cond: (id = pt1.id)
+         ->  Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+               Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 ptmismatch)
+   NESTED_LOOP_PLAIN(ptmismatch)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+    ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+   PARTITIONWISE(pt1 ptmismatch)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+    ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
new file mode 100644
index 00000000000..a80de78d823
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -0,0 +1,757 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+       QUERY PLAN        
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                     QUERY PLAN                     
+----------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+            QUERY PLAN             
+-----------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_b) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a > 0)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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 > 0;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a > 0)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (a > 0)
+   ->  Bitmap Index Scan on scan_table_pkey
+         Index Cond: (a > 0)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(9 rows)
+
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_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:
+   TID_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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table cilbup.scan_table_pkey) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, conflicting */
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched, conflicting */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Nested Loop Left Join
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s s#2)
+   INDEX_SCAN(s public.scan_table_pkey s#2 public.scan_table_pkey)
+   NO_GATHER(s s#2)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop Left Join
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s#2)
+   HASH_JOIN(s)
+   SEQ_SCAN(s)
+   INDEX_SCAN(s#2 public.scan_table_pkey)
+   NO_GATHER(s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s)
+   HASH_JOIN(s#2)
+   SEQ_SCAN(s#2)
+   INDEX_SCAN(s public.scan_table_pkey)
+   NO_GATHER(s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   HASH_JOIN(s s#2)
+   SEQ_SCAN(s s#2)
+   NO_GATHER(s s#2)
+(17 rows)
+
+COMMIT;
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(s@x)
+(5 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(s@unnamed_subquery)
+(5 rows)
+
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(s@unnamed_subquery)
+(7 rows)
+
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+          QUERY PLAN           
+-------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@x)
+   NO_GATHER(s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(s@unnamed_subquery)
+(7 rows)
+
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery)
+   NO_GATHER(s@unnamed_subquery)
+(7 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/syntax.out b/contrib/pg_plan_advice/expected/syntax.out
new file mode 100644
index 00000000000..dddb12cae58
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/syntax.out
@@ -0,0 +1,59 @@
+LOAD 'pg_plan_advice';
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQUENTIAL_SCAN(x)"
+DETAIL:  Could not parse advice: syntax error at or near "SEQUENTIAL_SCAN"
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN"
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(""
+DETAIL:  Could not parse advice: unterminated quoted identifier at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(#"
+DETAIL:  Could not parse advice: syntax error at or near "#"
+SET pg_plan_advice.advice = '()';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "()"
+DETAIL:  Could not parse advice: syntax error at or near "("
+SET pg_plan_advice.advice = '123';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "123"
+DETAIL:  Could not parse advice: syntax error at or near "123"
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "JOIN_ORDER("fOO") /* oops"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*/* stuff */*/"
+DETAIL:  Could not parse advice: syntax error at or near "*"
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN(a)"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN((a))"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
new file mode 100644
index 00000000000..3452e5ad48e
--- /dev/null
+++ b/contrib/pg_plan_advice/meson.build
@@ -0,0 +1,70 @@
+# Copyright (c) 2022-2024, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+  'pg_plan_advice.c',
+  'pgpa_ast.c',
+  'pgpa_collector.c',
+  'pgpa_identifier.c',
+  'pgpa_join.c',
+  'pgpa_output.c',
+  'pgpa_planner.c',
+  'pgpa_scan.c',
+  'pgpa_trove.c',
+  'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+  input: 'pgpa_scanner.l',
+  output: 'pgpa_scanner.c',
+  command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+  input: 'pgpa_parser.y',
+  kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+  pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_plan_advice',
+    '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice = shared_module('pg_plan_advice',
+  pg_plan_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_data(
+  'pg_plan_advice--1.0.sql',
+  'pg_plan_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gather',
+      'join_order',
+      'join_strategy',
+      'local_collector',
+      'partitionwise',
+      'scan',
+      'syntax',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_regress.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice--1.0.sql b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
new file mode 100644
index 00000000000..29f4f224864
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
@@ -0,0 +1,42 @@
+/* contrib/pg_plan_advice/pg_plan_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_plan_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_plan_advice/pg_plan_advice.c b/contrib/pg_plan_advice/pg_plan_advice.c
new file mode 100644
index 00000000000..f32e8b7a0d3
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.c
@@ -0,0 +1,454 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ *	  main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static pgpa_shared_state *pgpa_state = NULL;
+static dsa_area *pgpa_dsa_area = NULL;
+
+/* GUC variables */
+char	   *pg_plan_advice_advice = NULL;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+int			pg_plan_advice_local_collection_limit = 0;
+int			pg_plan_advice_shared_collection_limit = 0;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int	es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+												  DefElem *opt,
+												  ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+												 IntoClause *into,
+												 ExplainState *es,
+												 const char *queryString,
+												 ParamListInfo params,
+												 QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+											 GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("pg_plan_advice.advice",
+							   "advice to apply during query planning",
+							   NULL,
+							   &pg_plan_advice_advice,
+							   NULL,
+							   PGC_USERSET,
+							   0,
+							   pg_plan_advice_advice_check_hook,
+							   NULL,
+							   NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+							 "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+							 NULL,
+							 &pg_plan_advice_always_explain_supplied_advice,
+							 true,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_plan_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_plan_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_plan_advice");
+
+	/* Get an ID that we can use to cache data in an ExplainState. */
+	es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+	/* Register the new EXPLAIN options implemented by this module. */
+	RegisterExtensionExplainOption("plan_advice",
+								   pg_plan_advice_explain_option_handler);
+
+	/* Install hooks */
+	pgpa_planner_install_hooks();
+	prev_explain_per_plan = explain_per_plan_hook;
+	explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgpa_init_shared_state(void *ptr)
+{
+	pgpa_shared_state *state = (pgpa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock, LWLockNewTrancheId("pg_plan_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_plan_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+	if (pgpa_memory_context == NULL)
+		pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_plan_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgpa_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ *
+ * Along the way, make sure the relevant LWLock tranches are registered.
+ */
+pgpa_shared_state *
+pg_plan_advice_attach(void)
+{
+	if (pgpa_state == NULL)
+	{
+		bool		found;
+
+		pgpa_state =
+			GetNamedDSMSegment("pg_plan_advice", sizeof(pgpa_shared_state),
+							   pgpa_init_shared_state, &found);
+	}
+
+	return pgpa_state;
+}
+
+/*
+ * Return a pointer to pg_plan_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_plan_advice_dsa_area(void)
+{
+	if (pgpa_dsa_area == NULL)
+	{
+		pgpa_shared_state *state = pg_plan_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgpa_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgpa_dsa_area);
+			state->area = dsa_get_handle(pgpa_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgpa_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgpa_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgpa_dsa_area;
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+									  ParseState *pstate)
+{
+	bool	   *plan_advice;
+
+	plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+	if (plan_advice == NULL)
+	{
+		plan_advice = palloc0_object(bool);
+		SetExplainExtensionState(es, es_extension_id, plan_advice);
+	}
+
+	*plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+									  char *value)
+{
+	char	   *s;
+
+	/* For non-text formats, it's best not to add any special handling. */
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		ExplainPropertyText(qlabel, value, es);
+		return;
+	}
+
+	/* In text format, if there is no data, display nothing. */
+	if (*qlabel == '\0')
+		return;
+
+	/*
+	 * It looks nicest to indent each line of the advice separately, beginning
+	 * on the line below the label.
+	 */
+	ExplainIndentText(es);
+	appendStringInfo(es->str, "%s:\n", qlabel);
+	es->indent++;
+	while ((s = strchr(value, '\n')) != NULL)
+	{
+		ExplainIndentText(es);
+		appendBinaryStringInfo(es->str, value, (s - value) + 1);
+		value = s + 1;
+	}
+
+	/* Don't interpret a terminal newline as a request for an empty line. */
+	if (*value != '\0')
+	{
+		ExplainIndentText(es);
+		appendStringInfo(es->str, "%s\n", value);
+	}
+
+	es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		appendStringInfo(&buf, "%s /* ", item->defname);
+		if ((flags & PGPA_TE_MATCH_FULL) != 0)
+		{
+			Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+			appendStringInfo(&buf, "matched");
+		}
+		else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+			appendStringInfo(&buf, "partially matched");
+		else
+			appendStringInfo(&buf, "not matched");
+		if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+			appendStringInfo(&buf, ", inapplicable");
+		if ((flags & PGPA_TE_CONFLICTING) != 0)
+			appendStringInfo(&buf, ", conflicting");
+		if ((flags & PGPA_TE_FAILED) != 0)
+			appendStringInfo(&buf, ", failed");
+		appendStringInfo(&buf, " */\n");
+	}
+
+	pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+										  buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+									 IntoClause *into,
+									 ExplainState *es,
+									 const char *queryString,
+									 ParamListInfo params,
+									 QueryEnvironment *queryEnv)
+{
+	bool	   *plan_advice = GetExplainExtensionState(es, es_extension_id);
+	DefElem    *pgpa_item;
+	List	   *pgpa_list;
+
+	if (prev_explain_per_plan)
+		prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+							  queryEnv);
+
+	/* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+	pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+										"pg_plan_advice");
+	pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+	/*
+	 * By default, if there is a record of attempting to apply advice during
+	 * query planning, we always output that information, but the user can set
+	 * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+	 * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+	 * was specified and not set to false.
+	 *
+	 * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+	 * statement -- the application of query advice may not have been
+	 * recorded, and therefore this won't be able to show anything.
+	 */
+	if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+							  (plan_advice != NULL && *plan_advice)))
+	{
+		DefElem    *feedback;
+
+		feedback = find_defelem_by_defname(pgpa_list, "feedback");
+		if (feedback != NULL)
+			pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+	}
+
+	/*
+	 * If the PLAN_ADVICE option was specified -- and not sent to FALSE --
+	 * show generated advice.
+	 */
+	if (plan_advice != NULL && *plan_advice)
+	{
+		DefElem    *advice_string_item;
+		char	   *advice_string;
+
+		advice_string_item =
+			find_defelem_by_defname(pgpa_list, "advice_string");
+		if (advice_string_item != NULL)
+		{
+			/* Advice has already been generated; we can reuse it. */
+			advice_string = strVal(advice_string_item->arg);
+		}
+		else
+		{
+			pgpa_plan_walker_context walker;
+			StringInfoData buf;
+			pgpa_identifier *rt_identifiers;
+
+			/* Advice not yet generated; do that now. */
+			pgpa_plan_walker(&walker, plannedstmt);
+			rt_identifiers =
+				pgpa_create_identifiers_for_planned_stmt(plannedstmt);
+			initStringInfo(&buf);
+			pgpa_output_advice(&buf, &walker, rt_identifiers);
+			advice_string = buf.data;
+		}
+
+		if (advice_string[0] != '\0')
+			pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+												  advice_string);
+	}
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+	MemoryContext oldcontext;
+	MemoryContext tmpcontext;
+	char	   *error;
+
+	if (*newval == NULL)
+		return true;
+
+	tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+									   "pg_plan_advice.advice",
+									   ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+	/*
+	 * It would be nice to save the parse tree that we construct here for
+	 * eventual use when planning with this advice, but *extra can only point
+	 * to a single guc_malloc'd chunk, and our parse tree involves an
+	 * arbitrary number of memory allocations.
+	 */
+	(void) pgpa_parse(*newval, &error);
+
+	if (error != NULL)
+	{
+		GUC_check_errdetail("Could not parse advice: %s", error);
+		return false;
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+	MemoryContextDelete(tmpcontext);
+
+	return true;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.control b/contrib/pg_plan_advice/pg_plan_advice.control
new file mode 100644
index 00000000000..aa6fdc9e7b2
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.control
@@ -0,0 +1,5 @@
+# pg_plan_advice extension
+comment = 'help the planner get the right plan'
+default_version = '1.0'
+module_pathname = '$libdir/pg_plan_advice'
+relocatable = true
diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
new file mode 100644
index 00000000000..86efb3b6113
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -0,0 +1,37 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ *	  main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "nodes/plannodes.h"
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgpa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgpa_shared_state;
+
+/* GUC variables */
+extern int	pg_plan_advice_local_collection_limit;
+extern int	pg_plan_advice_shared_collection_limit;
+extern char *pg_plan_advice_advice;
+
+/* Function prototypes */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern pgpa_shared_state *pg_plan_advice_attach(void);
+extern dsa_area *pg_plan_advice_dsa_area(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
new file mode 100644
index 00000000000..02ffbfa3760
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -0,0 +1,392 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ *	  additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+										  pgpa_advice_target *target,
+										  bool *rids_used);
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+	switch (advice_tag)
+	{
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_FOREIGN_JOIN:
+			return "FOREIGN_JOIN";
+		case PGPA_TAG_GATHER:
+			return "GATHER";
+		case PGPA_TAG_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPA_TAG_HASH_JOIN:
+			return "HASH_JOIN";
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_TAG_INDEX_SCAN:
+			return "INDEX_SCAN";
+		case PGPA_TAG_JOIN_ORDER:
+			return "JOIN_ORDER";
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case PGPA_TAG_NO_GATHER:
+			return "NO_GATHER";
+		case PGPA_TAG_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+		case PGPA_TAG_SEQ_SCAN:
+			return "SEQ_SCAN";
+		case PGPA_TAG_TID_SCAN:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and reurn an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+	*fail = false;
+
+	switch (tag[0])
+	{
+		case 'b':
+			if (strcmp(tag, "bitmap_heap_scan") == 0)
+				return PGPA_TAG_BITMAP_HEAP_SCAN;
+			break;
+		case 'f':
+			if (strcmp(tag, "foreign_join") == 0)
+				return PGPA_TAG_FOREIGN_JOIN;
+			break;
+		case 'g':
+			if (strcmp(tag, "gather") == 0)
+				return PGPA_TAG_GATHER;
+			if (strcmp(tag, "gather_merge") == 0)
+				return PGPA_TAG_GATHER_MERGE;
+			break;
+		case 'h':
+			if (strcmp(tag, "hash_join") == 0)
+				return PGPA_TAG_HASH_JOIN;
+			break;
+		case 'i':
+			if (strcmp(tag, "index_scan") == 0)
+				return PGPA_TAG_INDEX_SCAN;
+			if (strcmp(tag, "index_only_scan") == 0)
+				return PGPA_TAG_INDEX_ONLY_SCAN;
+			break;
+		case 'j':
+			if (strcmp(tag, "join_order") == 0)
+				return PGPA_TAG_JOIN_ORDER;
+			break;
+		case 'm':
+			if (strcmp(tag, "merge_join_materialize") == 0)
+				return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+			if (strcmp(tag, "merge_join_plain") == 0)
+				return PGPA_TAG_MERGE_JOIN_PLAIN;
+			break;
+		case 'n':
+			if (strcmp(tag, "nested_loop_materialize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+			if (strcmp(tag, "nested_loop_memoize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+			if (strcmp(tag, "nested_loop_plain") == 0)
+				return PGPA_TAG_NESTED_LOOP_PLAIN;
+			if (strcmp(tag, "no_gather") == 0)
+				return PGPA_TAG_NO_GATHER;
+			break;
+		case 'p':
+			if (strcmp(tag, "partitionwise") == 0)
+				return PGPA_TAG_PARTITIONWISE;
+			break;
+		case 's':
+			if (strcmp(tag, "semijoin_non_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+			if (strcmp(tag, "semijoin_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_UNIQUE;
+			if (strcmp(tag, "seq_scan") == 0)
+				return PGPA_TAG_SEQ_SCAN;
+			break;
+		case 't':
+			if (strcmp(tag, "tid_scan") == 0)
+				return PGPA_TAG_TID_SCAN;
+			break;
+	}
+
+	/* didn't work out */
+	*fail = true;
+
+	/* return an arbitrary value to unwind the call stack */
+	return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		bool		first = true;
+		char	   *delims;
+
+		if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+			delims = "{}";
+		else
+			delims = "()";
+
+		appendStringInfoChar(str, delims[0]);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_advice_target(str, child_target);
+		}
+		appendStringInfoChar(str, delims[1]);
+	}
+	else
+	{
+		const char *rt_identifier;
+
+		rt_identifier = pgpa_identifier_string(&target->rid);
+		appendStringInfoString(str, rt_identifier);
+	}
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+	if (itarget->itype != PGPA_INDEX_NAME)
+	{
+		bool		first = true;
+
+		if (itarget->itype == PGPA_INDEX_AND)
+			appendStringInfoString(str, "&&(");
+		else
+			appendStringInfoString(str, "||(");
+
+		foreach_ptr(pgpa_index_target, child_target, itarget->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_index_target(str, child_target);
+		}
+		appendStringInfoChar(str, ')');
+	}
+	else
+	{
+		if (itarget->indnamespace != NULL)
+			appendStringInfo(str, "%s.",
+							 quote_identifier(itarget->indnamespace));
+		appendStringInfoString(str, quote_identifier(itarget->indname));
+	}
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+	if (i1->itype != i2->itype)
+		return false;
+
+	if (i1->itype == PGPA_INDEX_NAME)
+	{
+		/* indnamespace can be NULL, and two NULL values are equal */
+		if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+			(i1->indnamespace == NULL || i2->indnamespace == NULL ||
+			 strcmp(i1->indnamespace, i2->indnamespace) != 0))
+			return false;
+		if (strcmp(i1->indname, i2->indname) != 0)
+			return false;
+	}
+	else
+	{
+		int			i1_length = list_length(i1->children);
+
+		if (i1_length != list_length(i2->children))
+			return false;
+		for (int n = 0; n < i1_length; ++n)
+		{
+			pgpa_index_target *c1 = list_nth(i1->children, n);
+			pgpa_index_target *c2 = list_nth(i2->children, n);
+
+			if (!pgpa_index_targets_equal(c1, c2))
+				return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+	/* For non-identifiers, check all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (pgpa_identifier_matches_target(rid, child_target))
+				return true;
+		}
+		return false;
+	}
+
+	if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+		return false;
+	if (rid->occurrence != target->rid.occurrence)
+		return false;
+
+	/*
+	 * The identifier must specify a schema, but the target may leave the
+	 * schema NULL to match anything.
+	 */
+	if (target->rid.partnsp != NULL &&
+		strcmp(rid->partnsp, target->rid.partnsp) != 0)
+		return false;
+
+
+	/*
+	 * These fields can be NULL on either side, but NULL only matches another
+	 * NULL.
+	 */
+	if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+		return false;
+	if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+		return false;
+
+	return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target)
+{
+	bool		all_rids_used = true;
+	bool		any_rids_used = false;
+	bool		all_targets_used;
+	bool	   *rids_used = palloc0_array(bool, nrids);
+
+	all_targets_used =
+		pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+	for (int i = 0; i < nrids; ++i)
+	{
+		if (rids_used[i])
+			any_rids_used = true;
+		else
+			all_rids_used = false;
+	}
+
+	if (all_rids_used)
+	{
+		if (all_targets_used)
+			return PGPA_ITM_EQUAL;
+		else
+			return PGPA_ITM_KEYS_ARE_SUBSET;
+	}
+	else
+	{
+		if (all_targets_used)
+			return PGPA_ITM_TARGETS_ARE_SUBSET;
+		else if (any_rids_used)
+			return PGPA_ITM_INTERSECTING;
+		else
+			return PGPA_ITM_DISJOINT;
+	}
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target, bool *rids_used)
+{
+	bool		result = false;
+
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		result = true;
+
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+											   rids_used))
+				result = false;
+		}
+	}
+	else
+	{
+		for (int i = 0; i < nrids; ++i)
+		{
+			if (pgpa_identifier_matches_target(&rids[i], target))
+			{
+				rids_used[i] = true;
+				result = true;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
new file mode 100644
index 00000000000..f6fe730a4d4
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -0,0 +1,204 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ *	  abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+	PGPA_TARGET_IDENTIFIER,		/* relation identifier */
+	PGPA_TARGET_ORDERED_LIST,	/* (item ...) */
+	PGPA_TARGET_UNORDERED_LIST	/* {item ...} */
+} pgpa_target_type;
+
+/*
+ * When an advice item describes a bitmap index scan, it may need to describe
+ * the use of multiple indexes.
+ */
+typedef enum
+{
+	PGPA_INDEX_NAME,			/* index schema + name */
+	PGPA_INDEX_AND,				/* &&(item ...) */
+	PGPA_INDEX_OR				/* ||(item ...) */
+} pgpa_index_type;
+
+/*
+ * An index specification. We use this for INDEX_SCAN, INDEX_ONLY_SCAN,
+ * and BITMAP_HEAP_SCAN advice, but in the former two cases, the target must
+ * be of type PGPA_INDEX_NAME.
+ */
+typedef struct pgpa_index_target
+{
+	pgpa_index_type itype;
+
+	/* Index schem and name, when itype == PGPA_INDEX_NAME */
+	char	   *indnamespace;
+	char	   *indname;
+
+	/* List of pgpa_index_target objects, when itype != PGPA_INDEX_NAME */
+	List	   *children;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+	pgpa_target_type ttype;
+
+	/*
+	 * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+	 *
+	 * All identifiers must have an alias name and an occurrence number; the
+	 * remaining fields can be NULL. Note that it's possible to specify a
+	 * partition name without a partition schema, but not the reverse.
+	 */
+	pgpa_identifier rid;
+
+	/*
+	 * This field is set when ttype is PPGA_TARGET_IDENTIFIER and the advice
+	 * tag is PGPA_TAG_INDEX_SCAN, PGPA_TAG_INDEX_ONLY_SCAN, or
+	 * PGPA_TAG_BITMAP_HEAP_SCAN.
+	 */
+	pgpa_index_target *itarget;
+
+	/*
+	 * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+	 * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+	 */
+	List	   *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_FOREIGN_JOIN,
+	PGPA_TAG_GATHER,
+	PGPA_TAG_GATHER_MERGE,
+	PGPA_TAG_HASH_JOIN,
+	PGPA_TAG_INDEX_ONLY_SCAN,
+	PGPA_TAG_INDEX_SCAN,
+	PGPA_TAG_JOIN_ORDER,
+	PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+	PGPA_TAG_MERGE_JOIN_PLAIN,
+	PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+	PGPA_TAG_NESTED_LOOP_MEMOIZE,
+	PGPA_TAG_NESTED_LOOP_PLAIN,
+	PGPA_TAG_NO_GATHER,
+	PGPA_TAG_PARTITIONWISE,
+	PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+	PGPA_TAG_SEMIJOIN_UNIQUE,
+	PGPA_TAG_SEQ_SCAN,
+	PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+	pgpa_advice_tag_type tag;
+	List	   *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by an
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifeirs and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifeirs and targets were
+ * found.
+ */
+typedef enum
+{
+	PGPA_ITM_EQUAL,
+	PGPA_ITM_KEYS_ARE_SUBSET,
+	PGPA_ITM_TARGETS_ARE_SUBSET,
+	PGPA_ITM_INTERSECTING,
+	PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int	pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+					   char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner,
+						 const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int	pgpa_yyparse(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+										   pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+												   pgpa_identifier *rids,
+												   pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+									 pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+									  pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+									 pgpa_index_target *itarget);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_collector.c b/contrib/pg_plan_advice/pgpa_collector.c
new file mode 100644
index 00000000000..12085d9d75f
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.c
@@ -0,0 +1,637 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.c
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgpa_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgpa_collected_advice;
+
+/*
+ * A bunch of pointers to pgpa_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgpa_local_advice_chunk
+{
+	pgpa_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgpa_local_advice_chunk;
+
+/*
+ * Information about all of the pgpa_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgpa_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgpa_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgpa_local_advice_chunk **chunks;
+} pgpa_local_advice;
+
+/*
+ * Just like pgpa_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgpa_shared_advice_chunk;
+
+/*
+ * Just like pgpa_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgpa_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgpa_local_advice *local_collector = NULL;
+static pgpa_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgpa_collected_advice *pgpa_make_collected_advice(Oid userid,
+														 Oid dbid,
+														 uint64 queryId,
+														 TimestampTz timestamp,
+														 const char *query_string,
+														 const char *advice_string,
+														 dsa_area *area,
+														 dsa_pointer *result);
+static void pgpa_store_local_advice(pgpa_collected_advice *ca);
+static void pgpa_trim_local_advice(int limit);
+static void pgpa_store_shared_advice(dsa_pointer ca_pointer);
+static void pgpa_trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgpa_collected_advice */
+static inline const char *
+query_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgpa_collected_advice */
+static inline const char *
+advice_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pgpa_collect_advice(uint64 queryId, const char *query_string,
+					const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_plan_advice_local_collection_limit > 0)
+	{
+		pgpa_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+		ca = pgpa_make_collected_advice(userid, dbid, queryId, now,
+										query_string, advice_string,
+										NULL, NULL);
+		pgpa_store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_plan_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_plan_advice_dsa_area();
+		dsa_pointer ca_pointer;
+
+		pgpa_make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string, area,
+								   &ca_pointer);
+		pgpa_store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgpa_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgpa_collected_advice *
+pgpa_make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+						   TimestampTz timestamp,
+						   const char *query_string,
+						   const char *advice_string,
+						   dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgpa_collected_advice *ca;
+
+	total_length = offsetof(pgpa_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = GetUserId();
+	ca->dbid = MyDatabaseId;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pg_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+pgpa_store_local_advice(pgpa_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgpa_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgpa_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgpa_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgpa_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_local_advice(pg_plan_advice_local_collection_limit);
+}
+
+/*
+ * Add a pg_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_plan_advice DSA area
+ * and should point to an object of type pgpa_collected_advice.
+ */
+static void
+pgpa_store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	pgpa_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgpa_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgpa_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_shared_advice(area, pg_plan_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_local_advice(int limit)
+{
+	pgpa_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgpa_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgpa_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_shared_advice(dsa_area *area, int limit)
+{
+	pgpa_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(pgpa_shared_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		pgpa_trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	pgpa_trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice *sa = shared_collector;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_plan_advice/pgpa_collector.h b/contrib/pg_plan_advice/pgpa_collector.h
new file mode 100644
index 00000000000..b6e746a06d7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.h
@@ -0,0 +1,18 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.h
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_COLLECTOR_H
+#define PGPA_COLLECTOR_H
+
+extern void pgpa_collect_advice(uint64 queryId, const char *query_string,
+								const char *advice_string);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_identifier.c b/contrib/pg_plan_advice/pgpa_identifier.c
new file mode 100644
index 00000000000..a5fa77e083c
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.c
@@ -0,0 +1,476 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ *	  create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted.  Identifier-style escaping is applied to alias_name and
+ * subquery_name.  Whenever we include child_table_name, we always
+ * schema-qualified name, but writing their own plan advice are not required
+ * to do so.  Identifier-style escaping is applied to the schema and to the
+ * relation names separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+									  List *appinfos);
+static int	pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+								   SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+	const char *result;
+
+	Assert(rid->alias_name != NULL);
+	result = quote_identifier(rid->alias_name);
+
+	Assert(rid->occurrence >= 0);
+	if (rid->occurrence > 1)
+		result = psprintf("%s#%d", result, rid->occurrence);
+
+	if (rid->partrel != NULL)
+	{
+		if (rid->partnsp == NULL)
+			result = psprintf("%s/%s", result,
+							  quote_identifier(rid->partrel));
+		else
+			result = psprintf("%s/%s.%s", result,
+							  quote_identifier(rid->partnsp),
+							  quote_identifier(rid->partrel));
+	}
+
+	if (rid->plan_name != NULL)
+		result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+	return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+							   pgpa_identifier *rid)
+{
+	Index		top_rti = rti;
+	int			occurrence = 1;
+	RangeTblEntry *rte;
+	RangeTblEntry *top_rte;
+	char	   *partnsp = NULL;
+	char	   *partrel = NULL;
+
+	/*
+	 * If this is a child RTE, find the topmost parent that is still of type
+	 * RTE_RELATION. We do this because we identify children of partitioned
+	 * tables by the name of the child table, but subqueries can also have
+	 * child rels and we don't care about those here.
+	 */
+	for (;;)
+	{
+		AppendRelInfo *appinfo;
+		RangeTblEntry *parent_rte;
+
+		/* append_rel_array can be NULL if there are no children */
+		if (root->append_rel_array == NULL ||
+			(appinfo = root->append_rel_array[top_rti]) == NULL)
+			break;
+
+		parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+		if (parent_rte->rtekind != RTE_RELATION)
+			break;
+
+		top_rti = appinfo->parent_relid;
+	}
+
+	/* Get the range table entries for the RTI and top RTI. */
+	rte = planner_rt_fetch(rti, root);
+	top_rte = planner_rt_fetch(top_rti, root);
+	Assert(rte->rtekind != RTE_JOIN);
+	Assert(top_rte->rtekind != RTE_JOIN);
+
+	/* Work out the correct occurrence number. */
+	for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+		AppendRelInfo *appinfo;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 *
+		 * NB: append_rel_array can be NULL if there are no children
+		 */
+		if (root->append_rel_array != NULL &&
+			(appinfo = root->append_rel_array[prior_rti]) != NULL)
+		{
+			RangeTblEntry *parent_rte;
+
+			parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+			if (parent_rte->rtekind == RTE_RELATION)
+				continue;
+		}
+
+		/* Skip NULL entries and joins. */
+		prior_rte = planner_rt_fetch(prior_rti, root);
+		if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	/* If this is a child table, get the schema and relation names. */
+	if (rti != top_rti)
+	{
+		partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+		partrel = get_rel_name(rte->relid);
+	}
+
+	/* OK, we have all the answers we need. Return them to the caller. */
+	rid->alias_name = top_rte->eref->aliasname;
+	rid->occurrence = occurrence;
+	rid->partnsp = partnsp;
+	rid->partrel = partrel;
+	rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+								   pgpa_identifier *rids)
+{
+	int			count = 0;
+	int			rti = -1;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = planner_rt_fetch(rti, root);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+		pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+	}
+
+	Assert(count > 0);
+	return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+	Index		rtable_length = list_length(pstmt->rtable);
+	pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+	Index	   *top_rti_map;
+	int			rtinfoindex = 0;
+	SubPlanRTInfo *rtinfo = NULL;
+	SubPlanRTInfo *nextrtinfo = NULL;
+
+	/*
+	 * Account for relations addded by inheritance expansion of partitioned
+	 * tables.
+	 */
+	top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+										  pstmt->appendRelations);
+
+	/*
+	 * When we begin iterating, we're processing the portion of the range
+	 * table that originated from the top-level PlannerInfo, so subrtinfo is
+	 * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+	 * portion of the range table we are processing. nextrtinfo is always the
+	 * SubPlanRTInfo that follows the current one, if any, so when we're
+	 * processing the top-level query's portion of the range table, the next
+	 * SubPlanRTInfo is the very first one.
+	 */
+	if (pstmt->subrtinfos != NULL)
+		nextrtinfo = linitial(pstmt->subrtinfos);
+
+	/* Main loop over the range table. */
+	for (Index rti = 1; rti <= rtable_length; rti++)
+	{
+		const char *plan_name;
+		Index		top_rti;
+		RangeTblEntry *rte;
+		RangeTblEntry *top_rte;
+		char	   *partnsp = NULL;
+		char	   *partrel = NULL;
+		int			occurrence;
+		pgpa_identifier *rid;
+
+		/*
+		 * Advance to the next SubPlanRTInfo, if it's time to do that.
+		 *
+		 * This loop probably shouldn't ever iterate more than once, because
+		 * that would imply that a subquery was planned but added nothing to
+		 * the range table; but let's be defensive and assume it can happen.
+		 */
+		while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+		{
+			rtinfo = nextrtinfo;
+			if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+				nextrtinfo = NULL;
+			else
+				nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+		}
+
+		/* Fetch the range table entry, if any. */
+		rte = rt_fetch(rti, pstmt->rtable);
+
+		/*
+		 * We can't and don't need to identify null entries, and we don't want
+		 * to identify join entries.
+		 */
+		if (rte == NULL || rte->rtekind == RTE_JOIN)
+			continue;
+
+		/*
+		 * If this is not a relation added by partitioned table expansion,
+		 * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+		 * we need the information for the top RTI/RTE, and must also fetch
+		 * the partition schema and name.
+		 */
+		top_rti = top_rti_map[rti - 1];
+		if (rti == top_rti)
+			top_rte = rte;
+		else
+		{
+			top_rte = rt_fetch(top_rti, pstmt->rtable);
+			partnsp =
+				get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+			partrel = get_rel_name(rte->relid);
+		}
+
+		/* Compute the correct occurrence number. */
+		occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+											rtinfo, top_rti);
+
+		/* Get the name of the current plan (NULL for toplevel query). */
+		plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+		/* Save all the details we've derived. */
+		rid = &result[rti - 1];
+		rid->alias_name = top_rte->eref->aliasname;
+		rid->occurrence = occurrence;
+		rid->partnsp = partnsp;
+		rid->partrel = partrel;
+		rid->plan_name = plan_name;
+	}
+
+	return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid)
+{
+	Index		result = 0;
+
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+	{
+		pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+		/* If there's no identifier for this RTI, skip it. */
+		if (rti_rid->alias_name == NULL)
+			continue;
+
+		/*
+		 * If it matches, return this RTI. As usual, an omitted partition
+		 * schema matches anything, but partition and plan names must either
+		 * match exactly or be omitted on both sides.
+		 */
+		if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+			rid->occurrence == rti_rid->occurrence &&
+			(rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+			 strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+			strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+			strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+		{
+			if (result != 0)
+			{
+				/* Multiple matches were found. */
+				return 0;
+			}
+			result = rti;
+		}
+	}
+
+	return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+	Index	   *top_rti_map = palloc0_array(Index, rtable_length);
+
+	/* Initially, make every RTI point to itself. */
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+		top_rti_map[rti - 1] = rti;
+
+	/* Update the map for each AppendRelInfo object. */
+	foreach_node(AppendRelInfo, appinfo, appinfos)
+	{
+		Index		parent_rti = appinfo->parent_relid;
+		RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+		/* If the parent is not RTE_RELATION, ignore this entry. */
+		if (parent_rte->rtekind != RTE_RELATION)
+			continue;
+
+		/*
+		 * Map the child to wherever we mapped the parent. Parents always
+		 * precede their children in the AppendRelInfo list, so this should
+		 * work out.
+		 */
+		top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+	}
+
+	return top_rti_map;
+}
+
+/*
+ * Find the occurence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+					   SubPlanRTInfo *rtinfo, Index rti)
+{
+	Index		rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+	int			occurrence = 1;
+	RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+	for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 */
+		if (top_rti_map[prior_rti - 1] != prior_rti)
+			break;
+
+		/* Skip joins. */
+		prior_rte = rt_fetch(prior_rti, rtable);
+		if (prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	return occurrence;
+}
diff --git a/contrib/pg_plan_advice/pgpa_identifier.h b/contrib/pg_plan_advice/pgpa_identifier.h
new file mode 100644
index 00000000000..b000d2b7081
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ *	  create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+	const char *alias_name;
+	int			occurrence;
+	const char *partnsp;
+	const char *partrel;
+	const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+	if (a == b)
+		return true;
+	else if (a == NULL || b == NULL)
+		return false;
+	else
+		return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+										   pgpa_identifier *rid);
+extern int	pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+											   Bitmapset *relids,
+											   pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+											  pgpa_identifier *rt_identifiers,
+											  pgpa_identifier *rid);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
new file mode 100644
index 00000000000..88f5327886f
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -0,0 +1,615 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	Plan	   *outer_subplan;
+	ElidedNode *outer_elided_node;
+	bool		outer_beneath_any_gather;
+	pgpa_join_strategy *strategy;
+	Plan	  **inner_subplans;
+	ElidedNode **inner_elided_nodes;
+	pgpa_join_unroller **inner_unrollers;
+	bool	   *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+											  Plan *plan,
+											  Plan **realouter,
+											  Plan **realinner,
+											  ElidedNode **elidedrealouter,
+											  ElidedNode **elidedrealinner,
+											  bool *found_any_outer_gather,
+											  bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+										   bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+									ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+	pgpa_join_unroller *join_unroller;
+
+	join_unroller = palloc0_object(pgpa_join_unroller);
+	join_unroller->nallocated = 4;
+	join_unroller->strategy =
+		palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+	join_unroller->inner_subplans =
+		palloc_array(Plan *, join_unroller->nallocated);
+	join_unroller->inner_elided_nodes =
+		palloc_array(ElidedNode *, join_unroller->nallocated);
+	join_unroller->inner_unrollers =
+		palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+	join_unroller->inner_beneath_any_gather =
+		palloc_array(bool, join_unroller->nallocated);
+
+	return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+				 bool beneath_any_gather,
+				 pgpa_join_unroller *join_unroller,
+				 pgpa_join_unroller **outer_join_unroller,
+				 pgpa_join_unroller **inner_join_unroller)
+{
+	pgpa_join_strategy strategy;
+	Plan	   *realinner,
+			   *realouter;
+	ElidedNode *elidedinner,
+			   *elidedouter;
+	int			n;
+	bool		found_any_outer_gather = false;
+	bool		found_any_inner_gather = false;
+
+	Assert(join_unroller != NULL);
+
+	/*
+	 * We need to pass the join_unroller object down through certain types of
+	 * plan nodes -- anything that's considered part of the join strategy, and
+	 * any other nodes that can occur in a join tree despite not being scans
+	 * or joins.
+	 *
+	 * This includes:
+	 *
+	 * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+	 * strategy,
+	 *
+	 * (2) Gather and Gather Merge nodes, which can occur at any point in the
+	 * join tree where the planner decided to initiate parallelism,
+	 *
+	 * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+	 * or GatherMerge,
+	 *
+	 * (4) Agg and Unique nodes, which can occur when we decide to make the
+	 * nullable side of a semijoin unique and then join the result, and
+	 *
+	 * (5) Result nodes with children, which can be added either to project to
+	 * enforce a one-time filter (but Result nodes without children are
+	 * degenerate scans or joins).
+	 */
+	if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+		|| IsA(plan, Gather) || IsA(plan, GatherMerge)
+		|| is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+		|| is_result_node_with_child(plan))
+	{
+		*outer_join_unroller = join_unroller;
+		return;
+	}
+
+	/*
+	 * Since we've already handled nodes that require pass-through treatment,
+	 * this should be an unrollable join.
+	 */
+	strategy = pgpa_decompose_join(walker, plan,
+								   &realouter, &realinner,
+								   &elidedouter, &elidedinner,
+								   &found_any_outer_gather,
+								   &found_any_inner_gather);
+
+	/* If our workspace is full, expand it. */
+	if (join_unroller->nused >= join_unroller->nallocated)
+	{
+		join_unroller->nallocated *= 2;
+		join_unroller->strategy =
+			repalloc_array(join_unroller->strategy,
+						   pgpa_join_strategy,
+						   join_unroller->nallocated);
+		join_unroller->inner_subplans =
+			repalloc_array(join_unroller->inner_subplans,
+						   Plan *,
+						   join_unroller->nallocated);
+		join_unroller->inner_elided_nodes =
+			repalloc_array(join_unroller->inner_elided_nodes,
+						   ElidedNode *,
+						   join_unroller->nallocated);
+		join_unroller->inner_beneath_any_gather =
+			repalloc_array(join_unroller->inner_beneath_any_gather,
+						   bool,
+						   join_unroller->nallocated);
+		join_unroller->inner_unrollers =
+			repalloc_array(join_unroller->inner_unrollers,
+						   pgpa_join_unroller *,
+						   join_unroller->nallocated);
+	}
+
+	/*
+	 * Since we're flattening outer-deep join trees, it follows that if the
+	 * outer side is still an unrollable join, it should be unrolled into this
+	 * same object. Otherwise, we've reached the limit of what we can unroll
+	 * into this object and must remember the outer side as the final outer
+	 * subplan.
+	 */
+	if (elidedouter == NULL && pgpa_is_join(realouter))
+		*outer_join_unroller = join_unroller;
+	else
+	{
+		join_unroller->outer_subplan = realouter;
+		join_unroller->outer_elided_node = elidedouter;
+		join_unroller->outer_beneath_any_gather =
+			beneath_any_gather || found_any_outer_gather;
+	}
+
+	/*
+	 * Store the inner subplan. If it's an unrollable join, it needs to be
+	 * flattened in turn, but into a new unroller object, not this one.
+	 */
+	n = join_unroller->nused++;
+	join_unroller->strategy[n] = strategy;
+	join_unroller->inner_subplans[n] = realinner;
+	join_unroller->inner_elided_nodes[n] = elidedinner;
+	join_unroller->inner_beneath_any_gather[n] =
+		beneath_any_gather || found_any_inner_gather;
+	if (elidedinner == NULL && pgpa_is_join(realinner))
+		*inner_join_unroller = pgpa_create_join_unroller();
+	else
+		*inner_join_unroller = NULL;
+	join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+						 pgpa_join_unroller *join_unroller)
+{
+	pgpa_unrolled_join *ujoin;
+	int			i;
+
+	/*
+	 * We shouldn't have gone even so far as to create a join unroller unless
+	 * we found at least one unrollable join.
+	 */
+	Assert(join_unroller->nused > 0);
+
+	/* Allocate result structures. */
+	ujoin = palloc0_object(pgpa_unrolled_join);
+	ujoin->ninner = join_unroller->nused;
+	ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+	ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+	/* Handle the outermost join. */
+	ujoin->outer.plan = join_unroller->outer_subplan;
+	ujoin->outer.elided_node = join_unroller->outer_elided_node;
+	ujoin->outer.scan =
+		pgpa_build_scan(walker, ujoin->outer.plan,
+						ujoin->outer.elided_node,
+						join_unroller->outer_beneath_any_gather,
+						true);
+
+	/*
+	 * We want the joins from the deepest part of the plan tree to appear
+	 * first in the result object, but the join unroller adds them in exactly
+	 * the reverse of that order, so we need to flip the order of the arrays
+	 * when constructing the final result.
+	 */
+	for (i = 0; i < join_unroller->nused; ++i)
+	{
+		int			k = join_unroller->nused - i - 1;
+
+		/* Copy strategy, Plan, and ElidedNode. */
+		ujoin->strategy[i] = join_unroller->strategy[k];
+		ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+		ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+		/*
+		 * Fill in remaining details, using either the nested join unroller,
+		 * or by deriving them from the plan and elided nodes.
+		 */
+		if (join_unroller->inner_unrollers[k] != NULL)
+			ujoin->inner[i].unrolled_join =
+				pgpa_build_unrolled_join(walker,
+										 join_unroller->inner_unrollers[k]);
+		else
+			ujoin->inner[i].scan =
+				pgpa_build_scan(walker, ujoin->inner[i].plan,
+								ujoin->inner[i].elided_node,
+								join_unroller->inner_beneath_any_gather[i],
+								true);
+	}
+
+	return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+	pfree(join_unroller->strategy);
+	pfree(join_unroller->inner_subplans);
+	pfree(join_unroller->inner_elided_nodes);
+	pfree(join_unroller->inner_unrollers);
+	pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort note on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any correspoding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+					Plan **realouter, Plan **realinner,
+					ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+					bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+	PlannedStmt *pstmt = walker->pstmt;
+	JoinType	jointype = ((Join *) plan)->jointype;
+	Plan	   *outerplan = plan->lefttree;
+	Plan	   *innerplan = plan->righttree;
+	ElidedNode *elidedouter;
+	ElidedNode *elidedinner;
+	pgpa_join_strategy strategy;
+	bool		uniqueouter;
+	bool		uniqueinner;
+
+	elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+	elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+	*found_any_outer_gather = false;
+	*found_any_inner_gather = false;
+
+	switch (nodeTag(plan))
+	{
+		case T_MergeJoin:
+
+			/*
+			 * The planner may have chosen to place a Material node on the
+			 * inner side of the MergeJoin; if this is present, we record it
+			 * as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+			}
+			else
+				strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+			/*
+			 * For a MergeJoin, either the outer or the inner subplan, or
+			 * both, may have needed to be sorted; we must disregard any Sort
+			 * or IncrementalSort node to find the real inner or outer
+			 * subplan.
+			 */
+			if (elidedouter == NULL && is_sorting_plan(outerplan))
+				elidedouter = pgpa_descend_node(pstmt, &outerplan);
+			if (elidedinner == NULL && is_sorting_plan(innerplan))
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			break;
+
+		case T_NestLoop:
+
+			/*
+			 * The planner may have chosen to place a Material or Memoize node
+			 * on the inner side of the NestLoop; if this is present, we
+			 * record it as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+			}
+			else if (elidedinner == NULL && IsA(innerplan, Memoize))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+			}
+			else
+				strategy = JSTRAT_NESTED_LOOP_PLAIN;
+			break;
+
+		case T_HashJoin:
+
+			/*
+			 * The inner subplan of a HashJoin is always a Hash node; the real
+			 * inner subplan is the Hash node's child.
+			 */
+			Assert(IsA(innerplan, Hash));
+			Assert(elidedinner == NULL);
+			elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			strategy = JSTRAT_HASH_JOIN;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+	}
+
+	/*
+	 * The planner may have decided to implement a semijoin by first making
+	 * the nullable side of the plan unique, and then performing a normal join
+	 * against the result. Therefore, we might need to descend through a
+	 * unique node on either side of the plan.
+	 */
+	uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+	uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+	/*
+	 * The planner may have decided to parallelize part of the join tree, so
+	 * we could find a Gather or Gather Merge node here. Note that, if
+	 * present, this will appear below nodes we considered as part of the join
+	 * strategy, but we could find another uniqueness-enforcing node below the
+	 * Gather or Gather Merge, if present.
+	 */
+	if (elidedouter == NULL)
+	{
+		elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+											  found_any_outer_gather);
+		if (found_any_outer_gather &&
+			pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+			uniqueouter = true;
+	}
+	if (elidedinner == NULL)
+	{
+		elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+											  found_any_inner_gather);
+		if (found_any_inner_gather &&
+			pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+			uniqueinner = true;
+	}
+
+	/*
+	 * It's possible that Result node has been inserted either to project a
+	 * target list or to implement a one-time filter. If so, we can descend
+	 * throught it. Note that a result node without a child would be a
+	 * degenerate scan or join, and not something we could descend through.
+	 *
+	 * XXX. I suspect it's possible for this to happen above the Gather or
+	 * Gather Merge node, too, but apparently we have no test case for that
+	 * scenario.
+	 */
+	if (elidedouter == NULL && is_result_node_with_child(outerplan))
+		elidedouter = pgpa_descend_node(pstmt, &outerplan);
+	if (elidedinner == NULL && is_result_node_with_child(innerplan))
+		elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+	/*
+	 * If this is a semijoin that was converted to an inner join by making one
+	 * side or the other unique, make a note that the inner or outer subplan,
+	 * as appropriate, should be treated as a query plan feature when the main
+	 * tree traversal reaches it.
+	 *
+	 * Conversely, if the planner could have made one side of the join unique
+	 * and thereby converted it to an inner join, and chose not to do so, that
+	 * is also worth noting.
+	 *
+	 * XXX: We admit too much non-unique advice, as in the following example
+	 * from the regression tests: EXPLAIN (PLAN_ADVICE, COSTS OFF) DELETE FROM
+	 * prt1_l WHERE EXISTS (SELECT 1 FROM int4_tbl, LATERAL (SELECT
+	 * int4_tbl.f1 FROM int8_tbl LIMIT 2) ss WHERE prt1_l.c IS NULL). We emit
+	 * SEMIJOIN_NON_UNIQUE((int4_tbl ss)) but create_unique_path() fails in
+	 * this case, so there's no sj-unique version possible.
+	 *
+	 * NB: This code could appear slightly higher up in in this function, but
+	 * none of the nodes through which we just descended should have
+	 * associated RTIs.
+	 *
+	 * NB: This seems like a somewhat hacky way of passing information up to
+	 * the main tree walk, but I don't currently have a better idea.
+	 */
+	if (uniqueouter)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+	else if (jointype == JOIN_RIGHT_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+	if (uniqueinner)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+	else if (jointype == JOIN_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+	/* Set output parameters. */
+	*realouter = outerplan;
+	*realinner = innerplan;
+	*elidedrealouter = elidedouter;
+	*elidedrealinner = elidedinner;
+	return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+	*plan = (*plan)->lefttree;
+	return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+						bool *found_any_gather)
+{
+	if (IsA(*plan, Gather))
+	{
+		*found_any_gather = true;
+		return pgpa_descend_node(pstmt, plan);
+	}
+
+	if (IsA(*plan, GatherMerge))
+	{
+		ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+		if (elided == NULL && is_sorting_plan(*plan))
+			elided = pgpa_descend_node(pstmt, plan);
+
+		*found_any_gather = true;
+		return elided;
+	}
+
+	return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through at least one node, and
+ * otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+						ElidedNode **elided_node)
+{
+	if (*elided_node != NULL)
+		return false;
+
+	if (IsA(*plan, Agg) || IsA(*plan, Unique))
+	{
+		*elided_node = pgpa_descend_node(pstmt, plan);
+
+		if (*elided_node == NULL && is_sorting_plan(*plan))
+			*elided_node = pgpa_descend_node(pstmt, plan);
+
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+	return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+	return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
diff --git a/contrib/pg_plan_advice/pgpa_join.h b/contrib/pg_plan_advice/pgpa_join.h
new file mode 100644
index 00000000000..4dc72986a70
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.h
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+	JSTRAT_MERGE_JOIN_PLAIN = 0,
+	JSTRAT_MERGE_JOIN_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_PLAIN,
+	JSTRAT_NESTED_LOOP_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_MEMOIZE,
+	JSTRAT_HASH_JOIN
+	/* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY		((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+	Plan	   *plan;
+	ElidedNode *elided_node;
+	struct pgpa_scan *scan;
+	pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>.  When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+	/* Outermost member; must not itself be an unrolled join. */
+	pgpa_join_member outer;
+
+	/* Number of inner members. Length of the strategy and inner arrays. */
+	unsigned	ninner;
+
+	/* Array of strategies, one per non-outermost member. */
+	pgpa_join_strategy *strategy;
+
+	/* Array of members, excluding the outermost. Deepest first. */
+	pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+	return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+							 Plan *plan, bool beneath_any_gather,
+							 pgpa_join_unroller *join_unroller,
+							 pgpa_join_unroller **outer_join_unroller,
+							 pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+													pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
new file mode 100644
index 00000000000..89a675ff93e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -0,0 +1,628 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+	const char **rid_strings;
+	StringInfo	buf;
+	int			wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+									  pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+									pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+									  pgpa_scan_strategy strategy,
+									  List *scans);
+static void pgpa_output_bitmap_index_details(pgpa_output_context *context,
+											 Plan *plan);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+									  pgpa_qf_type type,
+									  List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+										char *strategy,
+										List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+								  Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+								  Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+				   pgpa_identifier *rt_identifiers)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	ListCell   *lc;
+	pgpa_output_context context;
+
+	/* Basic initialization. */
+	memset(&context, 0, sizeof(pgpa_output_context));
+	context.buf = buf;
+
+	/*
+	 * Convert identifiers to string form. Note that the loop variable here is
+	 * not an RTI, because RTIs are 1-based. Some RTIs will have no
+	 * identifier, either because the reloptkind is RTE_JOIN or because that
+	 * portion of the query didn't make it into the final plan.
+	 */
+	context.rid_strings = palloc0_array(const char *, rtable_length);
+	for (int i = 0; i < rtable_length; ++i)
+		if (rt_identifiers[i].alias_name != NULL)
+			context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+	/*
+	 * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+	 * from a psql client with default settings, psql will add one space to
+	 * the left of the output and EXPLAIN will add two more to the left of the
+	 * advice. Thus, lines of more than 77 characters will wrap. We set the
+	 * wrap limit to 76 here so that the output won't reach all the way to the
+	 * very last column of the terminal.
+	 *
+	 * Of course, this is fairly arbitrary set of assumptions, and one could
+	 * well make an argument for a different wrap limit, or for a configurable
+	 * one.
+	 */
+	context.wrap_column = 76;
+
+	/*
+	 * Each piece of JOIN_ORDER() advice fully describes the join order for a
+	 * a single unrolled join. Merging is not permitted, because that would
+	 * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+	 * scans should be used for all of those relations, and is thus equivalent
+	 * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+	 * is the driving table which is then joined to "b" then "c" then "d",
+	 * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+	 */
+	foreach(lc, walker->toplevel_unrolled_joins)
+	{
+		pgpa_unrolled_join *ujoin = lfirst(lc);
+
+		if (buf->len > 0)
+			appendStringInfoChar(buf, '\n');
+		appendStringInfo(context.buf, "JOIN_ORDER(");
+		pgpa_output_unrolled_join(&context, ujoin);
+		appendStringInfoChar(context.buf, ')');
+		pgpa_maybe_linebreak(context.buf, context.wrap_column);
+	}
+
+	/* Emit join strategy advice. */
+	for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+	{
+		char	   *strategy = pgpa_cstring_join_strategy(s);
+
+		pgpa_output_simple_strategy(&context,
+									strategy,
+									walker->join_strategies[s]);
+	}
+
+	/*
+	 * Emit scan strategy advice (but not for ordinary scans, which are
+	 * definitionally uninteresting).
+	 */
+	for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+		if (c != PGPA_SCAN_ORDINARY)
+			pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+	/* Emit query feature advice. */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+		pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+	/* Emit NO_GATHER advice. */
+	pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+						  pgpa_unrolled_join *join)
+{
+	pgpa_output_join_member(context, &join->outer);
+
+	for (int k = 0; k < join->ninner; ++k)
+	{
+		pgpa_join_member *member = &join->inner[k];
+
+		pgpa_maybe_linebreak(context->buf, context->wrap_column);
+		appendStringInfoChar(context->buf, ' ');
+		pgpa_output_join_member(context, member);
+	}
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+						pgpa_join_member *member)
+{
+	if (member->unrolled_join != NULL)
+	{
+		appendStringInfoChar(context->buf, '(');
+		pgpa_output_unrolled_join(context, member->unrolled_join);
+		appendStringInfoChar(context->buf, ')');
+	}
+	else
+	{
+		pgpa_scan  *scan = member->scan;
+
+		Assert(scan != NULL);
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '{');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, '}');
+		}
+	}
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+						  pgpa_scan_strategy strategy,
+						  List *scans)
+{
+	bool		first = true;
+
+	if (scans == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_scan_strategy(strategy));
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		Plan	   *plan = scan->plan;
+
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		/* Output the relation identifiers. */
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+
+		/* For scans involving indexes, output index information. */
+		if (strategy == PGPA_SCAN_INDEX)
+		{
+			Assert(IsA(plan, IndexScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_INDEX_ONLY)
+		{
+			Assert(IsA(plan, IndexOnlyScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context,
+									  ((IndexOnlyScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_BITMAP_HEAP)
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_bitmap_index_details(context, plan->lefttree);
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output information about which index or indexes power a BitmapHeapScan.
+ *
+ * We emit &&(i1 i2 i3) for a BitmapAnd between indexes i1, i2, and i3;
+ * and likewise ||(i1 i2 i3) for a similar BitmapOr operation.
+ */
+static void
+pgpa_output_bitmap_index_details(pgpa_output_context *context, Plan *plan)
+{
+	char	   *operator;
+	List	   *bitmapplans;
+	bool		first = true;
+
+	if (IsA(plan, BitmapIndexScan))
+	{
+		BitmapIndexScan *bitmapindexscan = (BitmapIndexScan *) plan;
+
+		pgpa_output_relation_name(context, bitmapindexscan->indexid);
+		return;
+	}
+
+	if (IsA(plan, BitmapOr))
+	{
+		operator = "||";
+		bitmapplans = ((BitmapOr *) plan)->bitmapplans;
+	}
+	else if (IsA(plan, BitmapAnd))
+	{
+		operator = "&&";
+		bitmapplans = ((BitmapAnd *) plan)->bitmapplans;
+	}
+	else
+		elog(ERROR, "unexpected node type: %d", (int) nodeTag(plan));
+
+	appendStringInfo(context->buf, "%s(", operator);
+	foreach_ptr(Plan, child_plan, bitmapplans)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+		pgpa_output_bitmap_index_details(context, child_plan);
+	}
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+	Oid			nspoid = get_rel_namespace(relid);
+	char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+	char	   *relname = get_rel_name(relid);
+
+	appendStringInfoString(context->buf, quote_identifier(relnamespace));
+	appendStringInfoChar(context->buf, '.');
+	appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+						  List *query_features)
+{
+	bool		first = true;
+
+	if (query_features == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_query_feature_type(type));
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(qf->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, qf->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, qf->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+							List *relid_sets)
+{
+	bool		first = true;
+
+	if (relid_sets == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(", strategy);
+
+	foreach_node(Bitmapset, relids, relid_sets)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+	if (relids == NULL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "NO_GATHER(");
+	pgpa_output_relations(context, context->buf, relids);
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+					  Bitmapset *relids)
+{
+	int			rti = -1;
+	bool		first = true;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		const char *rid_string = context->rid_strings[rti - 1];
+
+		if (rid_string == NULL)
+			elog(ERROR, "no identifier for RTI %d", rti);
+
+		if (first)
+		{
+			first = false;
+			appendStringInfoString(buf, rid_string);
+		}
+		else
+		{
+			pgpa_maybe_linebreak(buf, context->wrap_column);
+			appendStringInfo(buf, " %s", rid_string);
+		}
+	}
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+	switch (strategy)
+	{
+		case JSTRAT_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case JSTRAT_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case JSTRAT_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case JSTRAT_HASH_JOIN:
+			return "HASH_JOIN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+	switch (strategy)
+	{
+		case PGPA_SCAN_ORDINARY:
+			return "ORDINARY_SCAN";
+		case PGPA_SCAN_SEQ:
+			return "SEQ_SCAN";
+		case PGPA_SCAN_BITMAP_HEAP:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_SCAN_FOREIGN:
+			return "FOREIGN_JOIN";
+		case PGPA_SCAN_INDEX:
+			return "INDEX_SCAN";
+		case PGPA_SCAN_INDEX_ONLY:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_SCAN_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_SCAN_TID:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+	switch (type)
+	{
+		case PGPAQF_GATHER:
+			return "GATHER";
+		case PGPAQF_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPAQF_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPAQF_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+	}
+
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+	char	   *trailing_nl;
+	int			line_start;
+	int			save_cursor;
+
+	/* If line wrapping is disabled, exit quickly. */
+	if (wrap_column <= 0)
+		return;
+
+	/*
+	 * Set line_start to the byte offset within buf->data of the first
+	 * character of the current line, where the current line means the last
+	 * one in the buffer. Note that line_start could be the offset of the
+	 * trailing '\0' if the last character in the buffer is a line break.
+	 */
+	trailing_nl = strrchr(buf->data, '\n');
+	if (trailing_nl == NULL)
+		line_start = 0;
+	else
+		line_start = (trailing_nl - buf->data) + 1;
+
+	/*
+	 * Remember that the current end of the buffer is a potential location to
+	 * insert a line break on a future call to this function.
+	 */
+	save_cursor = buf->cursor;
+	buf->cursor = buf->len;
+
+	/* If we haven't passed the wrap column, we don't need a newline. */
+	if (buf->len - line_start <= wrap_column)
+		return;
+
+	/*
+	 * It only makes sense to insert a newline at a position later than the
+	 * beginning of the current line.
+	 */
+	if (buf->cursor <= line_start)
+		return;
+
+	/* Insert a newline at the previous cursor location. */
+	enlargeStringInfo(buf, 1);
+	memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+			buf->len - save_cursor);
+	++buf->cursor;
+	buf->data[++buf->len] = '\0';
+	buf->data[save_cursor] = '\n';
+}
diff --git a/contrib/pg_plan_advice/pgpa_output.h b/contrib/pg_plan_advice/pgpa_output.h
new file mode 100644
index 00000000000..47496d76f52
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+							   pgpa_plan_walker_context *walker,
+							   pgpa_identifier *rt_identifiers);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_parser.y b/contrib/pg_plan_advice/pgpa_parser.y
new file mode 100644
index 00000000000..4617e7f2f64
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_parser.y
@@ -0,0 +1,337 @@
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc.  This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE   pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+	char	   *str;
+	int			integer;
+	List	   *list;
+	pgpa_advice_item *item;
+	pgpa_advice_target *target;
+	pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_BITMAP TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+%token TOK_OR TOK_AND
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list bitmap_sublist bitmap_target_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> bitmap_target_item index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+		{
+			(void) yynerrs;				/* suppress compiler warning */
+			*result = $1;
+		}
+	;
+
+advice_item_list: advice_item_list advice_item
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_JOIN_ORDER;
+			$$->targets = $3;
+		}
+	| TOK_TAG_INDEX '(' index_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "index_only_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+			else if (strcmp($1, "index_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_BITMAP '(' bitmap_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+			$$->targets = $3;
+		}
+	| TOK_TAG_SIMPLE '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "no_gather") == 0)
+				$$->tag = PGPA_TAG_NO_GATHER;
+			else if (strcmp($1, "seq_scan") == 0)
+				$$->tag = PGPA_TAG_SEQ_SCAN;
+			else if (strcmp($1, "tid_scan") == 0)
+				$$->tag = PGPA_TAG_TID_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_GENERIC '(' generic_target_list ')'
+		{
+			bool	fail;
+
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = pgpa_parse_advice_tag($1, &fail);
+			if (fail)
+			{
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "unrecognized advice tag");
+			}
+
+			if ($$->tag == PGPA_TAG_FOREIGN_JOIN)
+			{
+				foreach_ptr(pgpa_advice_target, target, $3)
+				{
+					if (target->ttype == PGPA_TARGET_IDENTIFIER ||
+						list_length(target->children) == 1)
+							pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+										 "FOREIGN_JOIN targets must contain more than one relation identifier");
+				}
+			}
+
+			$$->targets = $3;
+		}
+	;
+
+relation_identifier: identifier opt_ri_occurrence opt_partition opt_plan_name
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_IDENTIFIER;
+			$$->rid.alias_name = $1;
+			$$->rid.occurrence = $2;
+			if (list_length($3) == 2)
+			{
+				$$->rid.partnsp = linitial($3);
+				$$->rid.partrel = lsecond($3);
+			}
+			else if ($3 != NIL)
+				$$->rid.partrel = linitial($3);
+			$$->rid.plan_name = $4;
+		}
+	;
+
+index_name: identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indname = $1;
+		}
+	| identifier '.' identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indnamespace = $1;
+			$$->indname = $3;
+		}
+	;
+
+opt_ri_occurrence:
+	'#' TOK_INTEGER
+		{
+			if ($2 <= 0)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "only positive occurrence numbers are permitted");
+			$$ = $2;
+		}
+	|
+		{
+			/* The default occurrence number is 1. */
+			$$ = 1;
+		}
+	;
+
+identifier: TOK_IDENT
+	| TOK_TAG_JOIN_ORDER
+	| TOK_TAG_INDEX
+	| TOK_TAG_BITMAP
+	| TOK_TAG_SIMPLE
+	| TOK_TAG_GENERIC
+	;
+
+/*
+ * When generating advice, we always schema-qualify the partition name, but
+ * when parsing advice, we accept a specification that lacks one.
+ */
+opt_partition:
+	'/' TOK_IDENT '.' TOK_IDENT
+		{ $$ = list_make2($2, $4); }
+	| '/' TOK_IDENT
+		{ $$ = list_make1($2); }
+	|
+		{ $$ = NIL; }
+	;
+
+opt_plan_name:
+	'@' TOK_IDENT
+		{ $$ = $2; }
+	|
+		{ $$ = NULL; }
+	;
+
+bitmap_target_list: bitmap_target_list relation_identifier bitmap_target_item
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+bitmap_target_item: index_name
+		{ $$ = $1; }
+	| TOK_OR '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_OR;
+			$$->children = $3;
+		}
+	| TOK_AND '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_AND;
+			$$->children = $3;
+		}
+	;
+
+bitmap_sublist: bitmap_sublist bitmap_target_item
+		{ $$ = lappend($1, $2); }
+	| bitmap_target_item
+		{ $$ = list_make1($1); }
+	;
+
+generic_target_list: generic_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| generic_target_list generic_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+generic_sublist: '(' generic_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+index_target_list:
+	  index_target_list relation_identifier index_name
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_target_list: join_order_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| join_order_target_list join_order_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_sublist:
+	'(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	| '{' simple_target_list '}'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_UNORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+simple_target_list: simple_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+%%
+
+/*
+ * Parse an advice_string and return the resulting list of pgpa_advice_item
+ * objects. If a parse error occurs, instead return NULL.
+ *
+ * If the return value is NULL, *error_p will be set to the error message;
+ * otherwise, *error_p will be set to NULL.
+ */
+List *
+pgpa_parse(const char *advice_string, char **error_p)
+{
+	yyscan_t	scanner;
+	List	   *result;
+	char	   *error = NULL;
+
+	pgpa_scanner_init(advice_string, &scanner);
+	pgpa_yyparse(&result, &error, scanner);
+	pgpa_scanner_finish(scanner);
+
+	if (error != NULL)
+	{
+		*error_p = error;
+		return NULL;
+	}
+
+	*error_p = NULL;
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
new file mode 100644
index 00000000000..11c8065d15e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -0,0 +1,1707 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.c
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "common/hashfn_unstable.h"
+#include "nodes/makefuncs.h"
+#include "optimizer/extendplan.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/plancat.h"
+#include "optimizer/planner.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * When assertions are enabled, we try generating relation identifiers during
+ * planning, saving them in a hash table, and then cross-checking them against
+ * the ones generated after planning is complete.
+ */
+typedef struct pgpa_ri_checker_key
+{
+	char	   *plan_name;
+	Index		rti;
+} pgpa_ri_checker_key;
+
+typedef struct pgpa_ri_checker
+{
+	pgpa_ri_checker_key key;
+	uint32		status;
+	const char *rid_string;
+} pgpa_ri_checker;
+
+static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
+
+static inline bool
+pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
+{
+	if (a.rti != b.rti)
+		return false;
+	if (a.plan_name == NULL)
+		return (b.plan_name == NULL);
+	if (b.plan_name == NULL)
+		return false;
+	return strcmp(a.plan_name, b.plan_name) == 0;
+}
+
+#define SH_PREFIX			pgpa_ri_check
+#define SH_ELEMENT_TYPE		pgpa_ri_checker
+#define SH_KEY_TYPE			pgpa_ri_checker_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#endif
+
+typedef struct pgpa_planner_state
+{
+	ExplainState *explain_state;
+	pgpa_trove *trove;
+	MemoryContext trove_cxt;
+
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_check_hash *ri_check_hash;
+#endif
+} pgpa_planner_state;
+
+typedef struct pgpa_join_state
+{
+	/* Most-recently-considered outer rel. */
+	RelOptInfo *outerrel;
+
+	/* Most-recently-considered inner rel. */
+	RelOptInfo *innerrel;
+
+	/*
+	 * Array of relation identifiers for all members of this joinrel, with
+	 * outerrel idenifiers before innerrel identifiers.
+	 */
+	pgpa_identifier *rids;
+
+	/* Number of outer rel identifiers. */
+	int			outer_count;
+
+	/* Number of inner rel identifiers. */
+	int			inner_count;
+
+	/*
+	 * Trove lookup results.
+	 *
+	 * join_entries and rel_entries are arrays of entries, and join_indexes
+	 * and rel_indexes are the integer offsets within those arrays of entries
+	 * potentially relevant to us. The "join" fields correspond to a lookup
+	 * using PGPA_TROVE_LOOKUP_JOIN and the "rel" fields to a lookup using
+	 * PGPA_TROVE_LOOKUP_REL.
+	 */
+	pgpa_trove_entry *join_entries;
+	Bitmapset  *join_indexes;
+	pgpa_trove_entry *rel_entries;
+	Bitmapset  *rel_indexes;
+} pgpa_join_state;
+
+/* Saved hook values */
+static get_relation_info_hook_type prev_get_relation_info = NULL;
+static join_path_setup_hook_type prev_join_path_setup = NULL;
+static joinrel_setup_hook_type prev_joinrel_setup = NULL;
+static planner_setup_hook_type prev_planner_setup = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+
+/* Other global variabes */
+static int	planner_extension_id = -1;
+
+/* Function prototypes. */
+static void pgpa_get_relation_info(PlannerInfo *root,
+								   Oid relationObjectId,
+								   bool inhparent,
+								   RelOptInfo *rel);
+static void pgpa_joinrel_setup(PlannerInfo *root,
+							   RelOptInfo *joinrel,
+							   RelOptInfo *outerrel,
+							   RelOptInfo *innerrel,
+							   SpecialJoinInfo *sjinfo,
+							   List *restrictlist);
+static void pgpa_join_path_setup(PlannerInfo *root,
+								 RelOptInfo *joinrel,
+								 RelOptInfo *outerrel,
+								 RelOptInfo *innerrel,
+								 JoinType jointype,
+								 JoinPathExtraData *extra);
+static void pgpa_planner_setup(PlannerGlobal *glob, Query *parse,
+							   const char *query_string,
+							   double *tuple_fraction,
+							   ExplainState *es);
+static void pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string, PlannedStmt *pstmt);
+static void pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p,
+											  char *plan_name,
+											  pgpa_join_state *pjs);
+static void pgpa_planner_apply_join_path_advice(JoinType jointype,
+												uint64 *pgs_mask_p,
+												char *plan_name,
+												pgpa_join_state *pjs);
+static void pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+										   pgpa_trove_entry *scan_entries,
+										   Bitmapset *scan_indexes,
+										   pgpa_trove_entry *rel_entries,
+										   Bitmapset *rel_indexes);
+static uint64 pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag);
+static bool pgpa_join_order_permits_join(int outer_count, int inner_count,
+										 pgpa_identifier *rids,
+										 pgpa_trove_entry *entry);
+static bool pgpa_join_method_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+
+static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+										  pgpa_trove_lookup_type type,
+										  pgpa_identifier *rt_identifiers,
+										  pgpa_plan_walker_context *walker);
+
+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);
+
+/*
+ * Install planner-related hooks.
+ */
+void
+pgpa_planner_install_hooks(void)
+{
+	planner_extension_id = GetPlannerExtensionId("pg_plan_advice");
+	prev_get_relation_info = get_relation_info_hook;
+	get_relation_info_hook = pgpa_get_relation_info;
+	prev_joinrel_setup = joinrel_setup_hook;
+	joinrel_setup_hook = pgpa_joinrel_setup;
+	prev_join_path_setup = join_path_setup_hook;
+	join_path_setup_hook = pgpa_join_path_setup;
+	prev_planner_setup = planner_setup_hook;
+	planner_setup_hook = pgpa_planner_setup;
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgpa_planner_shutdown;
+}
+
+/*
+ * Hook function for get_relation_info().
+ *
+ * We can apply scan advice at this opint, and we also usee this as an
+ * opportunity to do range-table identifier cross-checking in assert-enabled
+ * builds.
+ *
+ * XXX: We currently emit useless advice like NO_GATHER("*RESULT*") for trivial
+ * queries. The advice is useless because get_relation_info isn't called for
+ * non-relation RTEs. We should either suppress the advice in such cases, or
+ * add a hook that can apply it.
+ */
+static void
+pgpa_get_relation_info(PlannerInfo *root, Oid relationObjectId,
+					   bool inhparent, RelOptInfo *rel)
+{
+	pgpa_planner_state *pps;
+
+	/* 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. */
+	if (pps != NULL)
+		pgpa_ri_checker_save(pps, root, rel);
+
+	/* If query advice was provided, search for relevant entries. */
+	if (pps != NULL && pps->trove != NULL)
+	{
+		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,
+						  &tresult_scan);
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+						  &tresult_rel);
+
+		/* If relevant entries were found, apply them. */
+		if (tresult_scan.indexes != NULL || tresult_rel.indexes != NULL)
+			pgpa_planner_apply_scan_advice(rel,
+										   tresult_scan.entries,
+										   tresult_scan.indexes,
+										   tresult_rel.entries,
+										   tresult_rel.indexes);
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_get_relation_info)
+		(*prev_get_relation_info) (root, relationObjectId, inhparent, rel);
+}
+
+/*
+ * Search for advice pertaining to a proposed join.
+ */
+static pgpa_join_state *
+pgpa_get_join_state(PlannerInfo *root, RelOptInfo *joinrel,
+					RelOptInfo *outerrel, RelOptInfo *innerrel)
+{
+	pgpa_planner_state *pps;
+	pgpa_join_state *pjs;
+	bool		new_pjs = false;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+	if (pps == NULL || pps->trove == NULL)
+	{
+		/* No advice applies to this query, hence none to this joinrel. */
+		return NULL;
+	}
+
+	/*
+	 * See whether we've previously associated a pgpa_join_state with this
+	 * joinrel. If we have not, we need to try to construct one. If we have,
+	 * then there are two cases: (a) if innerrel and outerrel are unchanged,
+	 * we can simply use it, and (b) if they have changed, we need to rejigger
+	 * the array of identifiers but can still skip the trove lookup.
+	 */
+	pjs = GetRelOptInfoExtensionState(joinrel, planner_extension_id);
+	if (pjs != NULL)
+	{
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+		{
+			/*
+			 * If there's no potentially relevant advice, then the presence of
+			 * this pgpa_join_state acts like a negative cache entry: it tells
+			 * us not to bother searching the trove for advice, because we
+			 * will not find any.
+			 */
+			return NULL;
+		}
+
+		if (pjs->outerrel == outerrel && pjs->innerrel == innerrel)
+		{
+			/* No updates required, so just return. */
+			/* XXX. Does this need to do something different under GEQO? */
+			return pjs;
+		}
+	}
+
+	/*
+	 * If there's no pgpa_join_state yet, we need to allocate one. Trove keys
+	 * will not get built for RTE_JOIN RTEs, so the array may end up being
+	 * larger than needed. It's not worth trying to compute a perfectly
+	 * accurate count here.
+	 */
+	if (pjs == NULL)
+	{
+		int			pessimistic_count = bms_num_members(joinrel->relids);
+
+		pjs = palloc0_object(pgpa_join_state);
+		pjs->rids = palloc_array(pgpa_identifier, pessimistic_count);
+		new_pjs = true;
+	}
+
+	/*
+	 * Either we just allocated a new pgpa_join_state, or the existing one
+	 * needs reconfiguring for a new innerrel and outerrel. The required array
+	 * size can't change, so we can overwrite the existing one.
+	 */
+	pjs->outerrel = outerrel;
+	pjs->innerrel = innerrel;
+	pjs->outer_count =
+		pgpa_compute_identifiers_by_relids(root, outerrel->relids, pjs->rids);
+	pjs->inner_count =
+		pgpa_compute_identifiers_by_relids(root, innerrel->relids,
+										   pjs->rids + pjs->outer_count);
+
+	/*
+	 * If we allocated a new pgpa_join_state, search our trove of advice for
+	 * relevant entries. The trove lookup will return the same results for
+	 * every outerrel/innerrel combination, so we don't need to repeat that
+	 * work every time.
+	 */
+	if (new_pjs)
+	{
+		pgpa_trove_result tresult;
+
+		/* Find join entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_JOIN,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->join_entries = tresult.entries;
+		pjs->join_indexes = tresult.indexes;
+
+		/* Find rel entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->rel_entries = tresult.entries;
+		pjs->rel_indexes = tresult.indexes;
+
+		/* Now that the new pgpa_join_state is fully valid, save a pointer. */
+		SetRelOptInfoExtensionState(joinrel, planner_extension_id, pjs);
+
+		/*
+		 * If there was no relevant advice found, just return NULL. This
+		 * pgpa_join_state will stick around as a sort of negative cache
+		 * entry, so that future calls for this same joinrel quickly return
+		 * NULL.
+		 */
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+			return NULL;
+	}
+
+	return pjs;
+}
+
+/*
+ * Enforce any provided advice that is relevant to any method of implementing
+ * this join.
+ *
+ * Although we're passed the outerrel and innerrel here, those are just
+ * whatever values happened to prompt the creation of this joinrel; they
+ * shouldn't really influence our choice of what advice to apply.
+ */
+static void
+pgpa_joinrel_setup(PlannerInfo *root, RelOptInfo *joinrel,
+				   RelOptInfo *outerrel, RelOptInfo *innerrel,
+				   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_joinrel_advice(&joinrel->pgs_mask,
+										  root->plan_name,
+										  pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_joinrel_setup)
+		(*prev_joinrel_setup) (root, joinrel, outerrel, innerrel,
+							   sjinfo, restrictlist);
+}
+
+/*
+ * Enforce any provided advice that is relevant to this particular method of
+ * implementing this particular join.
+ */
+static void
+pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
+					 RelOptInfo *outerrel, RelOptInfo *innerrel,
+					 JoinType jointype, JoinPathExtraData *extra)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_join_path_advice(jointype,
+											&extra->pgs_mask,
+											root->plan_name,
+											pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_join_path_setup)
+		(*prev_join_path_setup) (root, joinrel, outerrel, innerrel,
+								 jointype, extra);
+}
+
+/*
+ * Prepare advice for use by a query.
+ */
+static void
+pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
+				   double *tuple_fraction, ExplainState *es)
+{
+	pgpa_trove *trove = NULL;
+	pgpa_planner_state *pps;
+	bool		needs_pps = false;
+
+	/*
+	 * If any advice was provided, build a trove of advice for use during
+	 * planning.
+	 */
+	if (pg_plan_advice_advice != NULL && pg_plan_advice_advice[0] != '\0')
+	{
+		List	   *advice_items;
+		char	   *error;
+
+		/*
+		 * Parsing shouldn't fail here, because we must have previously parsed
+		 * successfully in pg_plan_advice_advice_check_hook, but if it does,
+		 * emit a warning.
+		 */
+		advice_items = pgpa_parse(pg_plan_advice_advice, &error);
+		if (error)
+			elog(WARNING, "could not parse advice: %s", error);
+
+		/*
+		 * It's possible that the advice string was non-empty but contained no
+		 * actual advice, e.g. it was all whitespace.
+		 */
+		if (advice_items != NIL)
+		{
+			trove = pgpa_build_trove(advice_items);
+			needs_pps = true;
+		}
+	}
+
+#ifdef USE_ASSERT_CHECKING
+
+	/*
+	 * If asserts are enabled, always build a private state object for
+	 * cross-checks.
+	 */
+	needs_pps = true;
+#endif
+
+	/* Initialize and store private state, if required. */
+	if (needs_pps)
+	{
+		pps = palloc0_object(pgpa_planner_state);
+		pps->explain_state = es;
+		pps->trove = trove;
+#ifdef USE_ASSERT_CHECKING
+		pps->ri_check_hash =
+			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
+#endif
+		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
+	}
+}
+
+/*
+ * Carry out whatever work we want to do after planning is complete.
+ */
+static void
+pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	pgpa_planner_state *pps;
+	pgpa_trove *trove = NULL;
+	ExplainState *es = NULL;
+	pgpa_plan_walker_context walker = {0};	/* placate compiler */
+	bool		do_advice_feedback;
+	bool		do_collect_advice;
+	List	   *pgpa_items = NIL;
+	pgpa_identifier *rt_identifiers = NULL;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
+	if (pps != NULL)
+	{
+		trove = pps->trove;
+		es = pps->explain_state;
+	}
+
+	/* If at least one collector is enabled, generate advice. */
+	do_collect_advice = (pg_plan_advice_local_collection_limit > 0 ||
+						 pg_plan_advice_shared_collection_limit > 0);
+
+	/* If we applied advice, generate feedback. */
+	do_advice_feedback = (trove != NULL && es != NULL);
+
+	/* If either of the above apply, analyze the resulting PlannedStmt. */
+	if (do_collect_advice || do_advice_feedback)
+	{
+		pgpa_plan_walker(&walker, pstmt);
+		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+	}
+
+	/*
+	 * If advice collection is enabled, put the advice in string form and send
+	 * it to the collector.
+	 */
+	if (do_collect_advice)
+	{
+		char	   *advice_string;
+		StringInfoData buf;
+
+		/* Generate a textual advice string. */
+		initStringInfo(&buf);
+		pgpa_output_advice(&buf, &walker, rt_identifiers);
+		advice_string = buf.data;
+
+		/* If the advice string is empty, don't bother collecting it. */
+		if (advice_string[0] != '\0')
+			pgpa_collect_advice(pstmt->queryId, query_string, advice_string);
+
+		/*
+		 * If we've gone to the trouble of generating an advice string, and if
+		 * we're inside EXPLAIN, save the string so we don't need to
+		 * regenerate it.
+		 */
+		if (es != NULL)
+			pgpa_items = lappend(pgpa_items,
+								 makeDefElem("advice_string",
+											 (Node *) makeString(advice_string),
+											 -1));
+	}
+
+	/*
+	 * If we are planning within EXPLAIN, make arrangements to allow EXPLAIN
+	 * to tell the user what has happened with the provided advice.
+	 *
+	 * NB: If EXPLAIN is used on a prepared is a prepared statement, planning
+	 * will have already happened happened without recording these details. We
+	 * could consider adding a GUC to cater to that scenario; or we could do
+	 * this work all the time, but that seems like too much overhead.
+	 */
+	if (do_advice_feedback)
+	{
+		List	   *feedback = NIL;
+
+		/*
+		 * Inject a Node-tree representation of all the trove-entry flags into
+		 * the PlannedStmt.
+		 */
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_SCAN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_JOIN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_REL,
+												rt_identifiers, &walker);
+
+		pgpa_items = lappend(pgpa_items, makeDefElem("feedback",
+													 (Node *) feedback,
+													 -1));
+	}
+
+	/* Push whatever data we're saving into the PlannedStmt. */
+	if (pgpa_items != NIL)
+		pstmt->extension_state =
+			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);
+}
+
+/*
+ * Enforce overall restrictions on a join relation that apply uniformly
+ * regardless of the choice of inner and outer rel.
+ */
+static void
+pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
+								  pgpa_join_state *pjs)
+{
+	int			i = -1;
+	int			flags;
+	bool		gather_conflict = false;
+	uint64		gather_mask = 0;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	bool		partitionwise_conflict = false;
+	int			partitionwise_outcome = 0;
+	Bitmapset  *partitionwise_partial_match = NULL;
+	Bitmapset  *partitionwise_full_match = NULL;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type itm;
+		bool		full_match = false;
+		uint64		my_gather_mask = 0;
+		int			my_partitionwise_outcome = 0;	/* >0 yes, <0 no */
+
+		/*
+		 * For GATHER and GATHER_MERGE, if the specified relations exactly
+		 * match this joinrel, do whatever the advice says; otherwise, don't
+		 * allow Gather or Gather Merge at this level. For NO_GATHER, there
+		 * must be a single target relation which must be included in this
+		 * joinrel, so just don't allow Gather or Gather Merge here, full
+		 * stop.
+		 */
+		if (entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			full_match = true;
+		}
+		else
+		{
+			int			total_count;
+
+			total_count = pjs->outer_count + pjs->inner_count;
+			itm = pgpa_identifiers_match_target(total_count, pjs->rids,
+												entry->target);
+			Assert(itm != PGPA_ITM_DISJOINT);
+
+			if (itm == PGPA_ITM_EQUAL)
+			{
+				full_match = true;
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+					my_partitionwise_outcome = 1;
+				else if (entry->tag == PGPA_TAG_GATHER)
+					my_gather_mask = PGS_GATHER;
+				else if (entry->tag == PGPA_TAG_GATHER_MERGE)
+					my_gather_mask = PGS_GATHER_MERGE;
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+			else
+			{
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else if (entry->tag == PGPA_TAG_GATHER ||
+						 entry->tag == PGPA_TAG_GATHER_MERGE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (full_match)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+
+		/*
+		 * Likewise, if we set my_partitionwise_outcome up above, then we (1)
+		 * make a note if the advice conflicted, (2) remember what the desired
+		 * outcome was, and (3) remember whether this was a full or partial
+		 * match.
+		 */
+		if (my_partitionwise_outcome != 0)
+		{
+			if (partitionwise_outcome != 0 &&
+				partitionwise_outcome != my_partitionwise_outcome)
+				partitionwise_conflict = true;
+			partitionwise_outcome = my_partitionwise_outcome;
+			if (full_match)
+				partitionwise_full_match =
+					bms_add_member(partitionwise_full_match, i);
+			else
+				partitionwise_partial_match =
+					bms_add_member(partitionwise_partial_match, i);
+		}
+	}
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched, and if
+	 * the set of targets exactly matched this relation, fully matched. If
+	 * there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
+
+	/* Likewise for partitionwise advice. */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (partitionwise_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		*pgs_mask_p &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		*pgs_mask_p |= gather_mask;
+	}
+
+	/*
+	 * If there is a non-conflicting partitionwise specification, enforce.
+	 *
+	 * To force a partitionwise join, we disable all the ordinary means of
+	 * performing a join, and instead only Append and MergeAppend paths here.
+	 * To prevent one, we just disable Append and MergeAppend.  Note that we
+	 * must not unset PGS_CONSIDER_PARTITIONWISE even when we don't want a
+	 * partitionwise join here, because we might want one at a higher level
+	 * that is constructing using paths from this level.
+	 */
+	if (partitionwise_outcome != 0 && !partitionwise_conflict)
+	{
+		if (partitionwise_outcome > 0)
+			*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) |
+				PGS_APPEND | PGS_MERGE_APPEND | PGS_CONSIDER_PARTITIONWISE;
+		else
+			*pgs_mask_p &= ~(PGS_APPEND | PGS_MERGE_APPEND);
+	}
+}
+
+/*
+ * Enforce restrictions on the join order or join method.
+ *
+ * Note that, although it is possible to view PARTITIONWISE advice as
+ * controlling the join method, we can't enforce it here, because the code
+ * path where this executes only deals with join paths that are built directly
+ * from a single outer path and a single inner path.
+ */
+static void
+pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
+									char *plan_name,
+									pgpa_join_state *pjs)
+{
+	int			i = -1;
+	Bitmapset  *jo_permit_indexes = NULL;
+	Bitmapset  *jo_deny_indexes = NULL;
+	Bitmapset  *jm_indexes = NULL;
+	bool		jm_conflict = false;
+	uint32		join_mask = 0;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->join_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->join_entries[i];
+		uint32		my_join_mask;
+
+		/* Handle join order advice. */
+		if (entry->tag == PGPA_TAG_JOIN_ORDER)
+		{
+			if (pgpa_join_order_permits_join(pjs->outer_count,
+											 pjs->inner_count,
+											 pjs->rids,
+											 entry))
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			else
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			continue;
+		}
+
+		/* Handle join strategy advice. */
+		my_join_mask = pgpa_join_strategy_mask_from_advice_tag(entry->tag);
+		if (my_join_mask != 0)
+		{
+			bool		permit;
+			bool		restrict_method;
+
+			if (entry->tag == PGPA_TAG_FOREIGN_JOIN)
+				permit = pgpa_opaque_join_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			else
+				permit = pgpa_join_method_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			if (!permit)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				jm_indexes = bms_add_member(jo_permit_indexes, i);
+				if (join_mask != 0 && join_mask != my_join_mask)
+					jm_conflict = true;
+				join_mask = my_join_mask;
+			}
+			continue;
+		}
+
+		/* Handle semijoin uniqueness advice. */
+		if (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE ||
+			entry->tag == PGPA_TAG_SEMIJOIN_NON_UNIQUE)
+		{
+			bool		advice_unique;
+			bool		jt_unique;
+			bool		jt_non_unique;
+			bool		restrict_method;
+
+			/* Advice wants to unique-ify and use a regular join? */
+			advice_unique = (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE);
+
+			/* Planner is trying to unique-ify and use a regular join? */
+			jt_unique = (jointype == JOIN_UNIQUE_INNER ||
+						 jointype == JOIN_UNIQUE_OUTER);
+
+			/* Planner is trying a semi-join, without unique-ifying? */
+			jt_non_unique = (jointype == JOIN_SEMI ||
+							 jointype == JOIN_RIGHT_SEMI);
+
+			/*
+			 * These advice tags behave very much like join method advice, in
+			 * that they want the inner side of the semijoin to match the
+			 * relations listed in the advice. Hence, we test whether join
+			 * method advice would enforce a join order restriction here, and
+			 * disallow the join if not.
+			 *
+			 * XXX. Think harder about right semijoins.
+			 */
+			if (!pgpa_join_method_permits_join(pjs->outer_count,
+											   pjs->inner_count,
+											   pjs->rids,
+											   entry,
+											   &restrict_method))
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				if (!jt_unique && !jt_non_unique)
+				{
+					/*
+					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
+					 * or SJ_NON_UNIQUE can be applied.
+					 */
+					entry->flags |= PGPA_TE_INAPPLICABLE;
+				}
+				else if (advice_unique != jt_unique)
+					jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+				else
+					jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			}
+			continue;
+		}
+	}
+
+	/*
+	 * If the advice indicates both that this join order is permissible and
+	 * also that it isn't, then mark advice related to the join order as
+	 * conflicting.
+	 */
+	if (jo_permit_indexes != NULL && jo_deny_indexes != NULL)
+	{
+		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * If more than one join method specification is relevant here and they
+	 * differ, mark them all as conflicting.
+	 */
+	if (jm_conflict)
+		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
+							 PGPA_TE_CONFLICTING);
+
+	/*
+	 * If we were advised to deny this join order, then do so. However, if we
+	 * were also advised to permit it, then do nothing, since the advice
+	 * conflicts.
+	 */
+	if (jo_deny_indexes != NULL && jo_permit_indexes == NULL)
+		*pgs_mask_p = 0;
+
+	/*
+	 * If we were advised to restrict the join method, then do so. However, if
+	 * we got conflicting join method advice or were also advised to reject
+	 * this join order completely, then instead do nothing.
+	 */
+	if (join_mask != 0 && !jm_conflict && jo_deny_indexes == NULL)
+		*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) | join_mask;
+}
+
+/*
+ * Translate an advice tag into a path generation strategy mask.
+ *
+ * This function can be called with tag types that don't represent join
+ * strategies. In such cases, we just return 0, which can't be confused with
+ * a valid mask.
+ */
+static uint64
+pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag)
+{
+	switch (tag)
+	{
+		case PGPA_TAG_FOREIGN_JOIN:
+			return PGS_FOREIGNJOIN;
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return PGS_MERGEJOIN_PLAIN;
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return PGS_MERGEJOIN_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return PGS_NESTLOOP_PLAIN;
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return PGS_NESTLOOP_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return PGS_NESTLOOP_MEMOIZE;
+		case PGPA_TAG_HASH_JOIN:
+			return PGS_HASHJOIN;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Does a certain item of join order advice permit a certain join?
+ */
+static bool
+pgpa_join_order_permits_join(int outer_count, int inner_count,
+							 pgpa_identifier *rids,
+							 pgpa_trove_entry *entry)
+{
+	bool		loop = true;
+	bool		sublist = false;
+	int			length;
+	int			outer_length;
+	pgpa_advice_target *target = entry->target;
+	pgpa_advice_target *prefix_target;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * Find the innermost sublist that contains all keys; if no sublist does,
+	 * then continue processing with the toplevel list.
+	 *
+	 * For example, if the advice says JOIN_ORDER(t1 t2 (t3 t4 t5)), then we
+	 * should evaluate joins that only involve t3, t4, and/or t5 against the
+	 * (t3 t4 t5) sublist, and others against the full list.
+	 *
+	 * Note that (1) outermost sublist is always ordered and (2) whenever we
+	 * zoom into an unordered sublist, we instantly accept the proposed join.
+	 * If the advice says JOIN_ORDER(t1 t2 {t3 t4 t5}), any approach to
+	 * joining t3, t4, and/or t5 is acceptable.
+	 */
+	while (loop)
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+		loop = false;
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_itm_type itm;
+
+			if (child_target->ttype == PGPA_TARGET_IDENTIFIER)
+				continue;
+
+			itm = pgpa_identifiers_match_target(outer_count + inner_count,
+												rids, child_target);
+			if (itm == PGPA_ITM_EQUAL || itm == PGPA_ITM_KEYS_ARE_SUBSET)
+			{
+				if (child_target->ttype == PGPA_TARGET_ORDERED_LIST)
+				{
+					target = child_target;
+					sublist = true;
+					loop = true;
+					break;
+				}
+				else
+				{
+					Assert(child_target->ttype == PGPA_TARGET_UNORDERED_LIST);
+					return true;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Try to find a prefix of the selected join order list that is exactly
+	 * equal to the outer side of the proposed join.
+	 */
+	length = list_length(target->children);
+	prefix_target = palloc0_object(pgpa_advice_target);
+	prefix_target->ttype = PGPA_TARGET_ORDERED_LIST;
+	for (outer_length = 1; outer_length <= length; ++outer_length)
+	{
+		pgpa_itm_type itm;
+
+		/* Avoid leaking memory in every loop iteration. */
+		if (prefix_target->children != NULL)
+			list_free(prefix_target->children);
+		prefix_target->children = list_copy_head(target->children,
+												 outer_length);
+
+		/* Search, hoping to find an exact match. */
+		itm = pgpa_identifiers_match_target(outer_count, rids, prefix_target);
+		if (itm == PGPA_ITM_EQUAL)
+			break;
+
+		/*
+		 * If the prefix of the join order list that we're considering
+		 * includes some but not all of the outer rels, we can make the prefix
+		 * longer to find an exact match. But the advice hasn't mentioned
+		 * everything that's part of our outer rel yet, but has mentioned
+		 * things that are not, then this join doesn't match the join order
+		 * list.
+		 */
+		if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+			return false;
+	}
+
+	/*
+	 * If the previous looped stopped before the prefix_target included the
+	 * entire join order list, then the next member of the join order list
+	 * must exactly match the inner side of the join.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), if the outer side of the
+	 * current join includes only t1, then the inner side must be exactly t2;
+	 * if the outer side includes both t1 and t2, then the inner side must
+	 * include exactly t3, t4, and t5.
+	 */
+	if (outer_length < length)
+	{
+		pgpa_advice_target *inner_target;
+		pgpa_itm_type itm;
+
+		inner_target = list_nth(target->children, outer_length);
+
+		itm = pgpa_identifiers_match_target(inner_count, rids + outer_count,
+											inner_target);
+
+		/*
+		 * Before returning, consider whether we need to mark this entry as
+		 * fully matched. If we found every item but one on the lefthand side
+		 * of the join and the last item on the righthand side of the join,
+		 * then the answer is yes.
+		 */
+		if (outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
+			entry->flags |= PGPA_TE_MATCH_FULL;
+
+		return (itm == PGPA_ITM_EQUAL);
+	}
+
+	/*
+	 * If we get here, then the outer side of the join includes the entirety
+	 * of the join order list. In this case, we behave differently depending
+	 * on whether we're looking at the top-level join order list or sublist.
+	 * At the top-level, we treat the specified list as mandating that the
+	 * actual join order has the given list as a prefix, but a sublist
+	 * requires an exact match.
+	 *
+	 * Exmaple: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), we must start by joining
+	 * all five of those relations and in that sequence, but once that is
+	 * done, it's OK to join any other rels that are part of the join problem.
+	 * This allows a user to specify the driving table and perhaps the first
+	 * few things to which it should be joined while leaving the rest of the
+	 * join order up the optimizer. But it seems like it would be surprising,
+	 * given that specification, if the user could add t6 to the (t3 t4 t5)
+	 * sub-join, so we don't allow that. If we did want to allow it, the logic
+	 * earlier in this function would require substantial adjustment: we could
+	 * allow the t3-t4-t5-t6 join to be built here, but the next step of
+	 * joining t1-t2 to the result would still be rejected.
+	 */
+	return !sublist;
+}
+
+/*
+ * Does a certain item of join method advice permit a certain join?
+ *
+ * Advice such as HASH_JOIN((x y)) means that there should be a hash join with
+ * exactly x and y on the inner side. Obviously, this means that if we are
+ * considering a join with exactly x and y on the inner side, we should enforce
+ * the use of a hash join. However, it also means that we must reject some
+ * incompatible join orders entirely.  For example, a join with exactly x
+ * and y on the outer side shouldn't be allowed, because such paths might win
+ * over the advice-driven path on cost.
+ *
+ * To accommodate these requirements, this function returns true if the join
+ * should be allowed and false if it should not. Furthermore, *restrict_method
+ * is set to true if the join method should be enforced and false if not.
+ */
+static bool
+pgpa_join_method_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	/*
+	 * If our inner rel mentions exactly the same relations as the advice
+	 * target, allow the join and enforce the join method restriction.
+	 *
+	 * If our inner rel mentions a superset of the target relations, allow the
+	 * join. The join we care about has already taken place, and this advice
+	 * imposes no further restrictions.
+	 */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If our outer rel mentions a supserset of the relations in the advice
+	 * target, no restrictions apply. The join we care has already taken
+	 * place, and this advice imposes no further restrictions.
+	 *
+	 * On the other hand, if our outer rel mentions exactly the relations
+	 * mentioned in the advice target, the planner is trying to reverse the
+	 * sides of the join as compared with our desired outcome. Reject that.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+	else if (outer_itm == PGPA_ITM_EQUAL)
+		return false;
+
+	/*
+	 * If the advice target mentions only a single relation, the test below
+	 * cannot ever pass, so save some work by exiting now.
+	 */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+		return false;
+
+	/*
+	 * If everything in the joinrel is appears in the advice target, we're
+	 * below the level of the join we want to control.
+	 *
+	 * For example, HASH_JOIN((x y)) doesn't restrict how x and y can be
+	 * joined.
+	 *
+	 * This lookup shouldn't return PGPA_ITM_DISJOINT, because any such advice
+	 * should not have been returned from the trove in the first place.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've already permitted all allowable cases, so reject this.
+	 *
+	 * If we reach this point, then the advice overlaps with this join but
+	 * isn't entirely contained within either side, and there's also at least
+	 * one relation present in the join that isn't mentioned by the advice.
+	 *
+	 * For instance, in the HASH_JOIN((x y)) example, we would reach here if x
+	 * were on one side of the join, y on the other, and at least one of the
+	 * two sides also included some other relation, say t. In that case,
+	 * accepting this join would allow the (x y t) joinrel to contain
+	 * non-disabled paths that do not put (x y) on the inner side of a hash
+	 * join; we could instead end up with something like (x JOIN t) JOIN y.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning an opaque join permit a certain join?
+ *
+ * By an opaque join, we mean one where the exact mechanism by which the
+ * join is performed is not visible to PostgreSQL. Currently this is the
+ * case only for foreign joins: FOREIGN_JOIN((x y z)) means that x, y, and
+ * z are joined on the remote side, but we know nothing about the join order
+ * or join methods used over there.
+ */
+static bool
+pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	if (join_itm == PGPA_ITM_EQUAL)
+	{
+		/*
+		 * We have an exact match, and should therefore allow the join and
+		 * enforce the use of the relevant opaque join method.
+		 */
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+	{
+		/*
+		 * If join_itm == PGPA_ITM_TARGETS_ARE_SUBSET, then the join we care
+		 * about has already taken place and no further restrictions apply.
+		 *
+		 * If join_itm == PGPA_ITM_KEYS_ARE_SUBSET, we're still building up to
+		 * the join we care about and have not introduced any extraneous
+		 * relations not named in the advice. Note that ForeignScan paths for
+		 * joins are built up from ForeignScan paths from underlying joins and
+		 * scans, so we must not disable this join when considering a subset
+		 * of the relations we ultimately want.
+		 */
+		return true;
+	}
+
+	/*
+	 * The advice overlaps the join, but at least one relation is present in
+	 * the join that isn't mentioned by the advice. We want to disable such
+	 * paths so that we actually push down the join as intended.
+	 */
+	return false;
+}
+
+/*
+ * Apply scan advice to a RelOptInfo.
+ *
+ * XXX. For bitmap heap scans, we're just ignoring the index information from
+ * the advice. That's not cool.
+ */
+static void
+pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+							   pgpa_trove_entry *scan_entries,
+							   Bitmapset *scan_indexes,
+							   pgpa_trove_entry *rel_entries,
+							   Bitmapset *rel_indexes)
+{
+	bool		gather_conflict = false;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	int			i = -1;
+	pgpa_trove_entry *scan_entry = NULL;
+	int			flags;
+	bool		scan_type_conflict = false;
+	Bitmapset  *scan_type_indexes = NULL;
+	Bitmapset  *scan_type_rel_indexes = NULL;
+	uint64		gather_mask = 0;
+	uint64		scan_type = 0;
+
+	/* 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;
+
+		/* Translate our advice tags to a scan strategy advice value. */
+		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+			my_scan_type = PGS_BITMAPSCAN;
+		else if (my_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN)
+			my_scan_type = PGS_INDEXONLYSCAN | PGS_CONSIDER_INDEXONLY;
+		else if (my_entry->tag == PGPA_TAG_INDEX_SCAN)
+			my_scan_type = PGS_INDEXSCAN;
+		else if (my_entry->tag == PGPA_TAG_SEQ_SCAN)
+			my_scan_type = PGS_SEQSCAN;
+		else if (my_entry->tag == PGPA_TAG_TID_SCAN)
+			my_scan_type = PGS_TIDSCAN;
+
+		/*
+		 * If this is understandable scan advice, hang on to the entry, the
+		 * inferred scan type type, and the index at which we found it.
+		 *
+		 * Also make a note if we see conflicting scan type advice. Note that
+		 * we regard two index specifications as conflicting unless they match
+		 * exactly. In theory, perhaps we could regard INDEX_SCAN(a c) and
+		 * 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 (scan_type != 0 && scan_type != my_scan_type)
+				scan_type_conflict = true;
+			if (!scan_type_conflict && scan_entry != NULL &&
+				my_entry->target->itarget != NULL &&
+				scan_entry->target->itarget != NULL &&
+				!pgpa_index_targets_equal(scan_entry->target->itarget,
+										  my_entry->target->itarget))
+				scan_type_conflict = true;
+			scan_entry = my_entry;
+			scan_type = my_scan_type;
+			scan_type_indexes = bms_add_member(scan_type_indexes, i);
+		}
+	}
+
+	/* Scrutinize available gather-related and partitionwise advice. */
+	i = -1;
+	while ((i = bms_next_member(rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *my_entry = &rel_entries[i];
+		uint64		my_gather_mask = 0;
+		bool		just_one_rel;
+
+		just_one_rel = my_entry->target->ttype == PGPA_TARGET_IDENTIFIER
+			|| list_length(my_entry->target->children) == 1;
+
+		/*
+		 * PARTITIONWISE behaves like a scan type, except that if there's more
+		 * than one relation targeted, it has no effect at this level.
+		 */
+		if (my_entry->tag == PGPA_TAG_PARTITIONWISE)
+		{
+			if (just_one_rel)
+			{
+				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
+
+				if (scan_type != 0 && scan_type != my_scan_type)
+					scan_type_conflict = true;
+				scan_entry = my_entry;
+				scan_type = my_scan_type;
+				scan_type_rel_indexes =
+					bms_add_member(scan_type_rel_indexes, i);
+			}
+			continue;
+		}
+
+		/*
+		 * GATHER and GATHER_MERGE applied to a single rel mean that we should
+		 * use the correspondings strategy here, while applying either to more
+		 * than one rel means we should not use those strategies here, but
+		 * rather at the level of the joinrel that corresponds to what was
+		 * specified. NO_GATHER can only be applied to single rels.
+		 *
+		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
+		 * equivalent to allowing the non-use of either form of Gather here.
+		 */
+		if (my_entry->tag == PGPA_TAG_GATHER ||
+			my_entry->tag == PGPA_TAG_GATHER_MERGE)
+		{
+			if (!just_one_rel)
+				my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			else if (my_entry->tag == PGPA_TAG_GATHER)
+				my_gather_mask = PGS_GATHER;
+			else
+				my_gather_mask = PGS_GATHER_MERGE;
+		}
+		else if (my_entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			Assert(just_one_rel);
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (just_one_rel)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+	}
+
+	/* Enforce choice of index. */
+	if (scan_entry != NULL && !scan_type_conflict &&
+		(scan_entry->tag == PGPA_TAG_INDEX_SCAN ||
+		 scan_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN))
+	{
+		pgpa_index_target *itarget = scan_entry->target->itarget;
+		IndexOptInfo *matched_index = NULL;
+
+		Assert(itarget->itype == PGPA_INDEX_NAME);
+
+		foreach_node(IndexOptInfo, index, rel->indexlist)
+		{
+			char	   *relname = get_rel_name(index->indexoid);
+			Oid			nspoid = get_rel_namespace(index->indexoid);
+			char	   *relnamespace = get_namespace_name(nspoid);
+
+			if (strcmp(itarget->indname, relname) == 0 &&
+				(itarget->indnamespace == NULL ||
+				 strcmp(itarget->indnamespace, relnamespace) == 0))
+			{
+				matched_index = index;
+				break;
+			}
+		}
+
+		if (matched_index == NULL)
+		{
+			/* Don't force the scan type if the index doesn't exist. */
+			scan_type = 0;
+
+			/* Mark advice as inapplicable. */
+			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
+								 PGPA_TE_INAPPLICABLE);
+		}
+		else
+		{
+			/* Retain this index and discard the rest. */
+			rel->indexlist = list_make1(matched_index);
+		}
+	}
+
+	/*
+	 * Mark all the scan method entries as fully matched; and if they specify
+	 * different things, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	if (scan_type_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
+	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched. Mark
+	 * the ones that included this relation as a target by itself as fully
+	 * matched. If there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
+
+	/* If there is a non-conflicting scan specification, enforce it. */
+	if (scan_type != 0 && !scan_type_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
+			  PGS_CONSIDER_INDEXONLY);
+		rel->pgs_mask |= scan_type;
+	}
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		rel->pgs_mask |= gather_mask;
+	}
+}
+
+/*
+ * Add feedback entries to for one trove slice to the provided list and
+ * return the resulting list.
+ *
+ * Feedback entries are generated from the trove entry's flags. It's assumed
+ * that the caller has already set all relevant flags with the exception of
+ * PGPA_TE_FAILED. We set that flag here if appropriate.
+ */
+static List *
+pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+							 pgpa_trove_lookup_type type,
+							 pgpa_identifier *rt_identifiers,
+							 pgpa_plan_walker_context *walker)
+{
+	pgpa_trove_entry *entries;
+	int			nentries;
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	pgpa_trove_lookup_all(trove, type, &entries, &nentries);
+	for (int i = 0; i < nentries; ++i)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+		DefElem    *item;
+
+		/*
+		 * If this entry was fully matched, check whether generating advice
+		 * from this plan would produce such an entry. If not, label the entry
+		 * as failed.
+		 */
+		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+			!pgpa_walker_would_advise(walker, rt_identifiers,
+									  entry->tag, entry->target))
+			entry->flags |= PGPA_TE_FAILED;
+
+		item = makeDefElem(pgpa_cstring_trove_entry(entry),
+						   (Node *) makeInteger(entry->flags), -1);
+		list = lappend(list, item);
+	}
+
+	return list;
+}
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * Fast hash function for a key consisting of an RTI and plan name.
+ */
+static uint32
+pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	hs.accum = key.rti;
+	fasthash_combine(&hs);
+
+	/* plan_name can be NULL */
+	if (key.plan_name == NULL)
+		sp_len = 0;
+	else
+		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+
+	/* hashfn_unstable.h recommends using string length as tweak */
+	return fasthash_final32(&hs, sp_len);
+}
+
+#endif
+
+/*
+ * Save the range table identifier for one relation for future cross-checking.
+ */
+static void
+pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
+					 RelOptInfo *rel)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_checker_key key;
+	pgpa_ri_checker *check;
+	pgpa_identifier rid;
+	const char *rid_string;
+	bool		found;
+
+	key.rti = bms_singleton_member(rel->relids);
+	key.plan_name = root->plan_name;
+	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
+	rid_string = pgpa_identifier_string(&rid);
+	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
+	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
+	check->rid_string = rid_string;
+#endif
+}
+
+/*
+ * 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_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	pgpa_ri_check_iterator it;
+	pgpa_ri_checker *check;
+
+	/* 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. */
+	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
+	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	{
+		int			rtoffset = 0;
+		const char *rid_string;
+		Index		flat_rti;
+
+		/*
+		 * 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.
+		 */
+		if (check->key.plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				/*
+				 * If rtinfo->dummy is set, then the subquery's range table
+				 * will only have been partially copied to the final range
+				 * table. Specifically, only RTE_RELATION entries and
+				 * 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
+				 * in the final rtable.
+				 *
+				 * With more complex logic, we could work around that problem
+				 * by remembering the whole contents of the subquery's rtable
+				 * during planning, determining which of those would have been
+				 * copied to the final rtable, and matching them up. But it
+				 * doesn't seem like a worthwhile endeavor for right now,
+				 * because RTIs from such subqueries won't appear in the plan
+				 * tree itself, just in the range table. Hence, we can neither
+				 * generate nor accept advice for them.
+				 */
+				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+					&& !rtinfo->dummy)
+				{
+					rtoffset = rtinfo->rtoffset;
+					Assert(rtoffset > 0);
+					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;
+		}
+
+		/*
+		 * check->key.rti is the RTI that we saw prior to range-table
+		 * flattening, so we must add the appropriate RT offset to get the
+		 * final RTI.
+		 */
+		flat_rti = check->key.rti + rtoffset;
+		Assert(flat_rti <= list_length(pstmt->rtable));
+
+		/* Assert that the string we compute now matches the previous one. */
+		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
+		Assert(strcmp(rid_string, check->rid_string) == 0);
+	}
+#endif
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
new file mode 100644
index 00000000000..7d40b910b00
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -0,0 +1,17 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.h
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_PLANNER_H
+#define PGPA_PLANNER_H
+
+extern void pgpa_planner_install_hooks(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
new file mode 100644
index 00000000000..e6dc19c2f45
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -0,0 +1,284 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.c
+ *	  analysis of scans in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+
+static pgpa_scan *pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								 pgpa_scan_strategy strategy,
+								 Bitmapset *relids,
+								 bool needs_no_gather);
+
+
+static RTEKind unique_nonjoin_rtekind(Bitmapset *relids, List *rtable);
+
+/*
+ * Build a pgpa_scan object for a Plan node and update the plan walker
+ * context as appopriate.  If this is an Append or MergeAppend scan, also
+ * build pgpa_scan for any scans that were consolidated into this one by
+ * Append/MergeAppend pull-up.
+ *
+ * If there is at least one ElidedNode for this plan node, pass the uppermost
+ * one as elided_node, else pass NULL.
+ *
+ * Set the 'beneath_any_gather' node if we are underneath a Gather or
+ * Gather Merge node.
+ *
+ * Set the 'within_join_problem' flag if we're inside of a join problem and
+ * not otherwise.
+ */
+pgpa_scan *
+pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+				ElidedNode *elided_node,
+				bool beneath_any_gather, bool within_join_problem)
+{
+	pgpa_scan_strategy strategy = PGPA_SCAN_ORDINARY;
+	Bitmapset  *relids = NULL;
+	int			rti = -1;
+	bool		needs_no_gather = !beneath_any_gather;
+	List	   *child_append_relid_sets = NIL;
+
+	if (elided_node != NULL)
+	{
+		NodeTag		elided_type = elided_node->elided_type;
+
+		/*
+		 * If setrefs processing elided an Append or MergeAppend node that had
+		 * only one surviving child, then this is a partitionwise "scan" --
+		 * which may really be a partitionwise join, but there's no need to
+		 * distinguish.
+		 *
+		 * If it's a trivial SubqueryScan that was elided, then this is an
+		 * "ordinary" scan i.e. one for which we need to generate advice
+		 * because the planner has not made any meaningful choice.
+		 */
+		relids = elided_node->relids;
+		if (elided_type == T_Append || elided_type == T_MergeAppend)
+			strategy = PGPA_SCAN_PARTITIONWISE;
+		else
+			strategy = PGPA_SCAN_ORDINARY;
+
+		/*
+		 * If this is an elided Append or MergeAppend node, we don't need to
+		 * emit NO_GATHER() because we'll also emit it for the underlying scan,
+		 * which is good enough.
+		 *
+		 * If it's an elided SubqueryScan, the same argument is likely to
+		 * apply, because the subquery probably contains references to tables,
+		 * and if it doesn't, then planner isn't likely to want to do it in
+		 * parallel anyway. But also, we can't implement NO_GATHER() advice for
+		 * a non-relation RTEKind, because get_relation_info() isn't called
+		 * in such cases. That should probably be fixed at some point, but
+		 * until then we shouldn't emit it.
+		 */
+		needs_no_gather = false;
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+	{
+		RangeTblEntry *rte;
+
+		relids = bms_make_singleton(rti);
+
+		switch (nodeTag(plan))
+		{
+			case T_SeqScan:
+				strategy = PGPA_SCAN_SEQ;
+				break;
+			case T_BitmapHeapScan:
+				strategy = PGPA_SCAN_BITMAP_HEAP;
+				break;
+			case T_IndexScan:
+				strategy = PGPA_SCAN_INDEX;
+				break;
+			case T_IndexOnlyScan:
+				strategy = PGPA_SCAN_INDEX_ONLY;
+				break;
+			case T_TidScan:
+			case T_TidRangeScan:
+				strategy = PGPA_SCAN_TID;
+				break;
+			default:
+
+				/*
+				 * This case includes a ForeignScan targeting a single
+				 * relation; no other strategy is possible in that case, but
+				 * see below, where things are different in multi-relation
+				 * cases.
+				 */
+				strategy = PGPA_SCAN_ORDINARY;
+
+				rte = rt_fetch(rti, walker->pstmt->rtable);
+				if (rte->rtekind != RTE_RELATION)
+					needs_no_gather = false;
+
+				break;
+		}
+	}
+	else if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		switch (nodeTag(plan))
+		{
+			case T_ForeignScan:
+
+				/*
+				 * If multiple relations are being targeted by a single
+				 * foreign scan, then the foreign join has been pushed to the
+				 * remote side, and we want that to be reflected in the
+				 * generated advice.
+				 */
+				strategy = PGPA_SCAN_FOREIGN;
+				break;
+			case T_Append:
+
+				/*
+				 * Append nodes can represent partitionwise scans of a a
+				 * relation, but when they implement a set operation, they are
+				 * just ordinary scans.
+				 */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+
+				/* NO_GATHER() should be emitted for underlying rels only. */
+				needs_no_gather = false;
+
+				/* Be sure to account for pulled-up scans. */
+				child_append_relid_sets =
+					((Append *) plan)->child_append_relid_sets;
+				break;
+			case T_MergeAppend:
+				/* Some logic here as for Append, above. */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+
+				/* NO_GATHER() should be emitted for underlying rels only. */
+				needs_no_gather = false;
+
+				/* Be sure to account for pulled-up scans. */
+				child_append_relid_sets =
+					((MergeAppend *) plan)->child_append_relid_sets;
+				break;
+			default:
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+
+	/*
+	 * If this is an Append or MergeAppend node into which subordinate Append
+	 * or MergeAppend paths were merged, each of those merged paths is
+	 * effectively another scan for which we need to account.
+	 */
+	foreach_node(Bitmapset, child_relids, child_append_relid_sets)
+	{
+		Bitmapset  *child_nonjoin_relids;
+
+		child_nonjoin_relids =
+			pgpa_filter_out_join_relids(child_relids,
+										walker->pstmt->rtable);
+		(void) pgpa_make_scan(walker, plan, strategy,
+							  child_nonjoin_relids, false);
+	}
+
+	/*
+	 * If this plan node has no associated RTIs, it's not a scan. When the
+	 * 'within_join_problem' flag is set, that's unexpected, so throw an
+	 * error, else return quietly.
+	 */
+	if (relids == NULL)
+	{
+		if (within_join_problem)
+			elog(ERROR, "plan node has no RTIs: %d", (int) nodeTag(plan));
+		return NULL;
+	}
+
+	return pgpa_make_scan(walker, plan, strategy, relids, needs_no_gather);
+}
+
+/*
+ * Create a single pgpa_scan object and update the pgpa_plan_walker_context.
+ */
+static pgpa_scan *
+pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+			   pgpa_scan_strategy strategy, Bitmapset *relids,
+			   bool needs_no_gather)
+{
+	pgpa_scan  *scan;
+
+	/* Create the scan object. */
+	scan = palloc(sizeof(pgpa_scan));
+	scan->plan = plan;
+	scan->strategy = strategy;
+	scan->relids = relids;
+
+	/* Add it to the appropriate list. */
+	walker->scans[scan->strategy] = lappend(walker->scans[scan->strategy],
+											scan);
+
+	/* Caller tells us whether NO_GATHER() advice for this scan is needed. */
+	if (needs_no_gather)
+		walker->no_gather_scans = bms_add_members(walker->no_gather_scans,
+												  scan->relids);
+
+	return scan;
+}
+
+/*
+ * Determine the unique rtekind of a set of relids.
+ */
+static RTEKind
+unique_nonjoin_rtekind(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	bool		first = true;
+	RTEKind		rtekind;
+
+	Assert(relids != NULL);
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		if (first)
+		{
+			rtekind = rte->rtekind;
+			first = false;
+		}
+		else if (rtekind != rte->rtekind)
+			elog(ERROR, "rtekind mismatch: %d vs. %d",
+				 rtekind, rte->rtekind);
+	}
+
+	if (first)
+		elog(ERROR, "no non-RTE_JOIN RTEs found");
+
+	return rtekind;
+}
diff --git a/contrib/pg_plan_advice/pgpa_scan.h b/contrib/pg_plan_advice/pgpa_scan.h
new file mode 100644
index 00000000000..3bb8726ff1e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.h
@@ -0,0 +1,85 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.h
+ *	  analysis of scans in Plan trees
+ *
+ * For purposes of this module, a "scan" includes (1) single plan nodes that
+ * scan multiple RTIs, such as a degenerate Result node that replaces what
+ * would otherwise have been a join, and (2) Append and MergeAppend nodes
+ * implementing a partitionwise scan or a partitionwise join. Said
+ * differently, scans are the leaves of the join tree for a single join
+ * problem.
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_SCAN_H
+#define PGPA_SCAN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+
+/*
+ * Scan strategies.
+ *
+ * PGPA_SCAN_ORDINARY is any scan strategy that isn't interesting to us
+ * because there is no meaningful planner decision involved. For example,
+ * the only way to scan a subquery is a SubqueryScan, and the only way to
+ * scan a VALUES construct is a ValuesScan. We need not care exactly which
+ * type of planner node was used in such cases, because the same thing will
+ * happen when replanning.
+ *
+ * PGPA_SCAN_ORDINARY also includes Result nodes that correspond to scans
+ * or even joins that are proved empty. We don't know whether or not the scan
+ * or join will still be provably empty at replanning time, but if it is,
+ * then no scan-type advice is needed, and if it's not, we can't recommend
+ * a scan type based on the current plan.
+ *
+ * PGPA_SCAN_PARTITIONWISE also lumps together scans and joins: this can
+ * be either a partitionwise scan of a partitioned table or a partitionwise
+ * join between several partitioned tables. Note that all decisions about
+ * whether or not to use partitionwise join are meaningful: no matter what
+ * we decided this time, we could do more or fewer things partitionwise the
+ * next time.
+ *
+ * PGPA_SCAN_FOREIGN is only used when there's more than one relation involved;
+ * a single-table foreign scan is classified as ordinary, since there is no
+ * decision to make in that case.
+ *
+ * Other scan strategies map one-to-one to plan nodes.
+ */
+typedef enum
+{
+	PGPA_SCAN_ORDINARY = 0,
+	PGPA_SCAN_SEQ,
+	PGPA_SCAN_BITMAP_HEAP,
+	PGPA_SCAN_FOREIGN,
+	PGPA_SCAN_INDEX,
+	PGPA_SCAN_INDEX_ONLY,
+	PGPA_SCAN_PARTITIONWISE,
+	PGPA_SCAN_TID
+	/* update NUM_PGPA_SCAN_STRATEGY if you add anything here */
+} pgpa_scan_strategy;
+
+#define NUM_PGPA_SCAN_STRATEGY	((int) PGPA_SCAN_TID + 1)
+
+/*
+ * All of the details we need regarding a scan.
+ */
+typedef struct pgpa_scan
+{
+	Plan	   *plan;
+	pgpa_scan_strategy strategy;
+	Bitmapset  *relids;
+} pgpa_scan;
+
+extern pgpa_scan *pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								  ElidedNode *elided_node,
+								  bool beneath_any_gather,
+								  bool within_join_problem);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scanner.l b/contrib/pg_plan_advice/pgpa_scanner.l
new file mode 100644
index 00000000000..be7d7ba13a6
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scanner.l
@@ -0,0 +1,299 @@
+%top{
+/*
+ * Scanner for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_scanner.l
+ */
+#include "postgres.h"
+
+#include "common/string.h"
+#include "nodes/miscnodes.h"
+#include "parser/scansup.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Extra data that we pass around when during scanning.
+ *
+ * 'litbuf' is used to implement the <xd> exclusive state, which handles
+ * double-quoted identifiers.
+ */
+typedef struct pgpa_yy_extra_type
+{
+	StringInfoData	litbuf;
+} pgpa_yy_extra_type;
+
+}
+
+%{
+/* LCOV_EXCL_START */
+
+#define YY_DECL \
+	extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result, \
+						  char **parse_error_msg_p, yyscan_t yyscanner)
+
+/* No reason to constrain amount of data slurped */
+#define YY_READ_BUF_SIZE 16777216
+
+/* Avoid exit() on fatal scanner errors (a bit ugly -- see yy_fatal_error) */
+#undef fprintf
+#define fprintf(file, fmt, msg)  fprintf_to_ereport(fmt, msg)
+
+static void
+fprintf_to_ereport(const char *fmt, const char *msg)
+{
+	ereport(ERROR, (errmsg_internal("%s", msg)));
+}
+%}
+
+%option reentrant
+%option bison-bridge
+%option 8bit
+%option never-interactive
+%option nodefault
+%option noinput
+%option nounput
+%option noyywrap
+%option noyyalloc
+%option noyyrealloc
+%option noyyfree
+%option warn
+%option prefix="pgpa_yy"
+%option extra-type="pgpa_yy_extra_type *"
+
+/*
+ * What follows is a severely stripped-down version of the core scanner. We
+ * only care about recognizing identifiers with or without identifier quoting
+ * (i.e. double-quoting), decimal integers, and a small handful of other
+ * things. Keep these rules in sync with src/backend/parser/scan.l. As in that
+ * file, we use an exclusive state called 'xc' for C-style comments, and an
+ * exclusive state called 'xd' for double-quoted identifiers.
+ */
+%x xc
+%x xd
+
+ident_start		[A-Za-z\200-\377_]
+ident_cont		[A-Za-z\200-\377_0-9\$]
+
+identifier		{ident_start}{ident_cont}*
+
+decdigit		[0-9]
+decinteger		{decdigit}(_?{decdigit})*
+
+space			[ \t\n\r\f\v]
+whitespace		{space}+
+
+dquote			\"
+xdstart			{dquote}
+xdstop			{dquote}
+xddouble		{dquote}{dquote}
+xdinside		[^"]+
+
+xcstart			\/\*
+xcstop			\*+\/
+xcinside		[^*/]+
+
+%%
+
+{whitespace}	{ /* ignore */ }
+
+{identifier}	{
+					char   *str;
+					bool	fail;
+					pgpa_advice_tag_type	tag;
+
+					/*
+					 * Unlike the core scanner, we don't truncate identifiers
+					 * here. There is no obvious reason to do so.
+					 */
+					str = downcase_identifier(yytext, yyleng, false, false);
+					yylval->str = str;
+
+					/*
+					 * If it's not a tag, just return TOK_IDENT; else, return
+					 * a token type based on how further parsing should
+					 * proceed.
+					 */
+					tag = pgpa_parse_advice_tag(str, &fail);
+					if (fail)
+						return TOK_IDENT;
+					else if (tag == PGPA_TAG_JOIN_ORDER)
+						return TOK_TAG_JOIN_ORDER;
+					else if (tag == PGPA_TAG_INDEX_SCAN ||
+							 tag == PGPA_TAG_INDEX_ONLY_SCAN)
+						return TOK_TAG_INDEX;
+					else if (tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+						return TOK_TAG_BITMAP;
+					else if (tag == PGPA_TAG_SEQ_SCAN ||
+							 tag == PGPA_TAG_TID_SCAN ||
+							 tag == PGPA_TAG_NO_GATHER)
+						return TOK_TAG_SIMPLE;
+					else
+						return TOK_TAG_GENERIC;
+				}
+
+{decinteger}	{
+					char   *endptr;
+
+					errno = 0;
+					yylval->integer = strtoint(yytext, &endptr, 10);
+					if (*endptr != '\0' || errno == ERANGE)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "integer out of range");
+					return TOK_INTEGER;
+				}
+
+{xcstart}		{
+					BEGIN(xc);
+				}
+
+{xdstart}		{
+					BEGIN(xd);
+					resetStringInfo(&yyextra->litbuf);
+				}
+
+"||"			{ return TOK_OR; }
+
+"&&"			{ return TOK_AND; }
+
+.				{ return yytext[0]; }
+
+<xc>{xcstop}	{
+					BEGIN(INITIAL);
+				}
+
+<xc>{xcinside}	{
+					/* discard multiple characters without slash or asterisk */
+				}
+
+<xc>.			{
+					/*
+					 * Discard any single character. flex prefers longer
+					 * matches, so this rule will never be picked when we could
+					 * have matched xcstop.
+					 *
+					 * NB: At present, we don't bother to support nested
+					 * C-style comments here, but this logic could be extended
+					 * if that restriction poses a problem.
+					 */
+				}
+
+<xc><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated comment");
+				}
+
+<xd>{xdstop}	{
+					BEGIN(INITIAL);
+					yylval->str = pstrdup(yyextra->litbuf.data);
+					return TOK_IDENT;
+				}
+
+<xd>{xddouble}	{
+					appendStringInfoChar(&yyextra->litbuf, '"');
+				}
+
+<xd>{xdinside}	{
+					appendBinaryStringInfo(&yyextra->litbuf, yytext, yyleng);
+				}
+
+<xd><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated quoted identifier");
+				}
+
+%%
+
+/* LCOV_EXCL_STOP */
+
+/*
+ * Handler for errors while scanning or parsing advice.
+ *
+ * bison passes the error message to us via 'message', and the context is
+ * available via the 'yytext' macro. We assemble those values into a final
+ * error text and then arrange to pass it back to the caller of pgpa_yyparse()
+ * by storing it into *parse_error_msg_p.
+ */
+void
+pgpa_yyerror(List **result, char **parse_error_msg_p, yyscan_t yyscanner,
+			 const char *message)
+{
+	struct yyguts_t *yyg = (struct yyguts_t *) yyscanner;	/* needed for yytext
+															 * macro */
+
+
+	/* report only the first error in a parse operation */
+	if (*parse_error_msg_p)
+		return;
+
+	if (yytext[0])
+		*parse_error_msg_p = psprintf("%s at or near \"%s\"", message, yytext);
+	else
+		*parse_error_msg_p = psprintf("%s at end of input", message);
+}
+
+/*
+ * Initialize the advice scanner.
+ *
+ * This should be called before parsing begins.
+ */
+void
+pgpa_scanner_init(const char *str, yyscan_t *yyscannerp)
+{
+	yyscan_t	yyscanner;
+	pgpa_yy_extra_type	*yyext = palloc0_object(pgpa_yy_extra_type);
+
+	if (yylex_init(yyscannerp) != 0)
+		elog(ERROR, "yylex_init() failed: %m");
+
+	yyscanner = *yyscannerp;
+
+	initStringInfo(&yyext->litbuf);
+	pgpa_yyset_extra(yyext, yyscanner);
+
+	yy_scan_string(str, yyscanner);
+}
+
+
+/*
+ * Shut down the advice scanner.
+ *
+ * This should be called after parsing is complete.
+ */
+void
+pgpa_scanner_finish(yyscan_t yyscanner)
+{
+	yylex_destroy(yyscanner);
+}
+
+/*
+ * Interface functions to make flex use palloc() instead of malloc().
+ * It'd be better to make these static, but flex insists otherwise.
+ */
+
+void *
+yyalloc(yy_size_t size, yyscan_t yyscanner)
+{
+	return palloc(size);
+}
+
+void *
+yyrealloc(void *ptr, yy_size_t size, yyscan_t yyscanner)
+{
+	if (ptr)
+		return repalloc(ptr, size);
+	else
+		return palloc(size);
+}
+
+void
+yyfree(void *ptr, yyscan_t yyscanner)
+{
+	if (ptr)
+		pfree(ptr);
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
new file mode 100644
index 00000000000..a92121feb1d
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -0,0 +1,490 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.c
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * This name comes from the English expression "trove of advice", which
+ * means a collection of wisdom. This slightly unusual term is chosen to
+ * avoid naming confusion; for example, "collection of advice" would
+ * invite confusion with pgpa_collector.c. Note that, while we don't know
+ * whether the provided advice is actually wise, it's not our job to
+ * question the user's choices.
+ *
+ * The goal of this module is to make it easy to locate the specific
+ * bits of advice that pertain to any given part of a query, or to
+ * determine that there are none.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_trove.h"
+
+#include "common/hashfn_unstable.h"
+
+/*
+ * An advice trove is organized into a series of "slices", each of which
+ * contains information about one topic e.g. scan methods. Each slice consists
+ * of an array of trove entries plus a hash table that we can use to determine
+ * which ones are relevant to a particular part of the query.
+ */
+typedef struct pgpa_trove_slice
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	pgpa_trove_entry *entries;
+	struct pgpa_trove_entry_hash *hash;
+} pgpa_trove_slice;
+
+/*
+ * Scan advice is stored into 'scan'; join advice is stored into 'join'; and
+ * advice that can apply to both cases is stored into 'rel'. This lets callers
+ * ask just for what's relevant. These slices correspond to the possible values
+ * of pgpa_trove_lookup_type.
+ */
+struct pgpa_trove
+{
+	pgpa_trove_slice join;
+	pgpa_trove_slice rel;
+	pgpa_trove_slice scan;
+};
+
+/*
+ * We're going to build a hash table to allow clients of this module to find
+ * relevant advice for a given part of the query quickly. However, we're going
+ * to use only three of the five key fields as hash keys. There are two reasons
+ * for this.
+ *
+ * First, it's allowable to set partition_schema to NULL to match a partition
+ * with the correct name in any schema.
+ *
+ * Second, we expect the "occurrence" and "partition_schema" portions of the
+ * relation identifiers to be mostly uninteresting. Most of the time, the
+ * occurrence field will be 1 and the partition_schema values will all be the
+ * same. Even when there is some variation, the absolute number of entries
+ * that have the same values for all three of these key fields should be
+ * quite small.
+ */
+typedef struct
+{
+	const char *alias_name;
+	const char *partition_name;
+	const char *plan_name;
+} pgpa_trove_entry_key;
+
+typedef struct
+{
+	pgpa_trove_entry_key key;
+	int			status;
+	Bitmapset  *indexes;
+} pgpa_trove_entry_element;
+
+static uint32 pgpa_trove_entry_hash_key(pgpa_trove_entry_key key);
+
+static inline bool
+pgpa_trove_entry_compare_key(pgpa_trove_entry_key a, pgpa_trove_entry_key b)
+{
+	if (strcmp(a.alias_name, b.alias_name) != 0)
+		return false;
+
+	if (!strings_equal_or_both_null(a.partition_name, b.partition_name))
+		return false;
+
+	if (!strings_equal_or_both_null(a.plan_name, b.plan_name))
+		return false;
+
+	return true;
+}
+
+#define SH_PREFIX			pgpa_trove_entry
+#define SH_ELEMENT_TYPE		pgpa_trove_entry_element
+#define SH_KEY_TYPE			pgpa_trove_entry_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_trove_entry_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_trove_entry_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static void pgpa_init_trove_slice(pgpa_trove_slice *tslice);
+static void pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+									pgpa_advice_tag_type tag,
+									pgpa_advice_target *target);
+static void pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash,
+								   pgpa_advice_target *target,
+								   int index);
+static Bitmapset *pgpa_trove_slice_lookup(pgpa_trove_slice *tslice,
+										  pgpa_identifier *rid);
+
+/*
+ * Build a trove of advice from a list of advice items.
+ *
+ * Caller can obtain a list of advice items to pass to this function by
+ * calling pgpa_parse().
+ */
+pgpa_trove *
+pgpa_build_trove(List *advice_items)
+{
+	pgpa_trove *trove = palloc_object(pgpa_trove);
+
+	pgpa_init_trove_slice(&trove->join);
+	pgpa_init_trove_slice(&trove->rel);
+	pgpa_init_trove_slice(&trove->scan);
+
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		switch (item->tag)
+		{
+			case PGPA_TAG_JOIN_ORDER:
+				{
+					pgpa_advice_target *target;
+
+					/*
+					 * For most advice types, each element in the top-level
+					 * list is a separate target, but it's most convenient to
+					 * regard the entirety of a JOIN_ORDER specification as a
+					 * single target. Since it wasn't represented that way
+					 * during parsing, build a surrogate object now.
+					 */
+					target = palloc0_object(pgpa_advice_target);
+					target->ttype = PGPA_TARGET_ORDERED_LIST;
+					target->children = item->targets;
+
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_INDEX_ONLY_SCAN:
+			case PGPA_TAG_INDEX_SCAN:
+			case PGPA_TAG_SEQ_SCAN:
+			case PGPA_TAG_TID_SCAN:
+
+				/*
+				 * Scan advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					/*
+					 * For now, all of our scan types target single relations,
+					 * but in the future this might not be true, e.g. a custom
+					 * scan could replace a join.
+					 */
+					Assert(target->ttype == PGPA_TARGET_IDENTIFIER);
+					pgpa_trove_add_to_slice(&trove->scan,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_FOREIGN_JOIN:
+			case PGPA_TAG_HASH_JOIN:
+			case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			case PGPA_TAG_MERGE_JOIN_PLAIN:
+			case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			case PGPA_TAG_NESTED_LOOP_PLAIN:
+			case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			case PGPA_TAG_SEMIJOIN_UNIQUE:
+
+				/*
+				 * Join strategy advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_PARTITIONWISE:
+			case PGPA_TAG_GATHER:
+			case PGPA_TAG_GATHER_MERGE:
+			case PGPA_TAG_NO_GATHER:
+
+				/*
+				 * Advice about a RelOptInfo relevant to both scans and joins.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->rel,
+											item->tag, target);
+				}
+				break;
+		}
+	}
+
+	return trove;
+}
+
+/*
+ * Search a trove of advice for relevant entries.
+ *
+ * All parameters are input parameters except for *result, which is an output
+ * parameter used to return results to the caller.
+ */
+void
+pgpa_trove_lookup(pgpa_trove *trove, pgpa_trove_lookup_type type,
+				  int nrids, pgpa_identifier *rids, pgpa_trove_result *result)
+{
+	pgpa_trove_slice *tslice;
+	Bitmapset  *indexes;
+
+	Assert(nrids > 0);
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	indexes = pgpa_trove_slice_lookup(tslice, &rids[0]);
+	for (int i = 1; i < nrids; ++i)
+	{
+		Bitmapset  *other_indexes;
+
+		/*
+		 * If the caller is asking about two relations that aren't part of the
+		 * same subquery, they've messed up.
+		 */
+		Assert(strings_equal_or_both_null(rids[0].plan_name,
+										  rids[i].plan_name));
+
+		other_indexes = pgpa_trove_slice_lookup(tslice, &rids[i]);
+		indexes = bms_union(indexes, other_indexes);
+	}
+
+	result->entries = tslice->entries;
+	result->indexes = indexes;
+}
+
+/*
+ * Return all entries in a trove slice to the caller.
+ *
+ * The first two arguments are input arguments, and the remainder are output
+ * arguments.
+ */
+void
+pgpa_trove_lookup_all(pgpa_trove *trove, pgpa_trove_lookup_type type,
+					  pgpa_trove_entry **entries, int *nentries)
+{
+	pgpa_trove_slice *tslice;
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	*entries = tslice->entries;
+	*nentries = tslice->nused;
+}
+
+/*
+ * Convert a trove entry to an item of plan advice that would produce it.
+ */
+char *
+pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	appendStringInfo(&buf, "%s", pgpa_cstring_advice_tag(entry->tag));
+
+	/* JOIN_ORDER tags are transformed by pgpa_build_trove; undo that here */
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, '(');
+	else
+		Assert(entry->target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	pgpa_format_advice_target(&buf, entry->target);
+
+	if (entry->target->itarget != NULL)
+	{
+		appendStringInfoChar(&buf, ' ');
+		pgpa_format_index_target(&buf, entry->target->itarget);
+	}
+
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, ')');
+
+	return buf.data;
+}
+
+/*
+ * Set PGPA_TE_* flags on a set of trove entries.
+ */
+void
+pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
+{
+	int			i = -1;
+
+	while ((i = bms_next_member(indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+
+		entry->flags |= flags;
+	}
+}
+
+/*
+ * Add a new advice target to an existing pgpa_trove_slice object.
+ */
+static void
+pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+						pgpa_advice_tag_type tag,
+						pgpa_advice_target *target)
+{
+	pgpa_trove_entry *entry;
+
+	if (tslice->nused >= tslice->nallocated)
+	{
+		int			new_allocated;
+
+		new_allocated = tslice->nallocated * 2;
+		tslice->entries = repalloc_array(tslice->entries, pgpa_trove_entry,
+										 new_allocated);
+		tslice->nallocated = new_allocated;
+	}
+
+	entry = &tslice->entries[tslice->nused];
+	entry->tag = tag;
+	entry->target = target;
+	entry->flags = 0;
+
+	pgpa_trove_add_to_hash(tslice->hash, target, tslice->nused);
+
+	tslice->nused++;
+}
+
+/*
+ * Update the hash table for a newly-added advice target.
+ */
+static void
+pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash, pgpa_advice_target *target,
+					   int index)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	bool		found;
+
+	/* For non-identifiers, add entries for all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_trove_add_to_hash(hash, child_target, index);
+		}
+		return;
+	}
+
+	/* Sanity checks. */
+	Assert(target->rid.occurrence > 0);
+	Assert(target->rid.alias_name != NULL);
+
+	/* Add an entry for this relation identifier. */
+	key.alias_name = target->rid.alias_name;
+	key.partition_name = target->rid.partrel;
+	key.plan_name = target->rid.plan_name;
+	element = pgpa_trove_entry_insert(hash, key, &found);
+	element->indexes = bms_add_member(element->indexes, index);
+}
+
+/*
+ * Create and initialize a new pgpa_trove_slice object.
+ */
+static void
+pgpa_init_trove_slice(pgpa_trove_slice *tslice)
+{
+	/*
+	 * In an ideal world, we'll make tslice->nallocated big enough that the
+	 * array and hash table will be large enough to contain the number of
+	 * advice items in this trove slice, but a generous default value is not
+	 * good for performance, because pgpa_init_trove_slice() has to zero an
+	 * amount of memory proportional to tslice->nallocated. Hence, we keep the
+	 * starting value quite small, on the theory that advice strings will
+	 * often be relatively short.
+	 */
+	tslice->nallocated = 16;
+	tslice->nused = 0;
+	tslice->entries = palloc_array(pgpa_trove_entry, tslice->nallocated);
+	tslice->hash = pgpa_trove_entry_create(CurrentMemoryContext,
+										   tslice->nallocated, NULL);
+}
+
+/*
+ * Fast hash function for a key consisting of alias_name, partition_name,
+ * and plan_name.
+ */
+static uint32
+pgpa_trove_entry_hash_key(pgpa_trove_entry_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	/* alias_name may not be NULL */
+	sp_len = fasthash_accum_cstring(&hs, key.alias_name);
+
+	/* partition_name and plan_name, however, can be NULL */
+	if (key.partition_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.partition_name);
+	if (key.plan_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.plan_name);
+
+	/*
+	 * hashfn_unstable.h recommends using string length as tweak. It's not
+	 * clear to me what to do if there are multiple strings, so for now I'm
+	 * just using the total of all of the lengths.
+	 */
+	return fasthash_final32(&hs, sp_len);
+}
+
+/*
+ * Look for matching entries.
+ */
+static Bitmapset *
+pgpa_trove_slice_lookup(pgpa_trove_slice *tslice, pgpa_identifier *rid)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	Bitmapset  *result = NULL;
+
+	Assert(rid->occurrence >= 1);
+
+	key.alias_name = rid->alias_name;
+	key.partition_name = rid->partrel;
+	key.plan_name = rid->plan_name;
+
+	element = pgpa_trove_entry_lookup(tslice->hash, key);
+
+	if (element != NULL)
+	{
+		int			i = -1;
+
+		while ((i = bms_next_member(element->indexes, i)) >= 0)
+		{
+			pgpa_trove_entry *entry = &tslice->entries[i];
+
+			/*
+			 * We know that this target or one of its descendents matches the
+			 * identifier on the three key fields above, but we don't know
+			 * which descendent or whether the occurence and schema also
+			 * match.
+			 */
+			if (pgpa_identifier_matches_target(rid, entry->target))
+				result = bms_add_member(result, i);
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
new file mode 100644
index 00000000000..479c3f75778
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -0,0 +1,113 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.h
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_TROVE_H
+#define PGPA_TROVE_H
+
+#include "pgpa_ast.h"
+
+#include "nodes/bitmapset.h"
+
+typedef struct pgpa_trove pgpa_trove;
+
+/*
+ * Flags that can be set on a pgpa_trove_entry to indicate what happened when
+ * trying to plan using advice.
+ *
+ * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_TE_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_TE_MATCH_PARTIAL		0x0001
+#define PGPA_TE_MATCH_FULL			0x0002
+#define PGPA_TE_INAPPLICABLE		0x0004
+#define PGPA_TE_CONFLICTING			0x0008
+#define PGPA_TE_FAILED				0x0010
+
+/*
+ * Each entry in a trove of advice represents the application of a tag to
+ * a single target.
+ */
+typedef struct pgpa_trove_entry
+{
+	pgpa_advice_tag_type tag;
+	pgpa_advice_target *target;
+	int			flags;
+} pgpa_trove_entry;
+
+/*
+ * What kind of information does the caller want to find in a trove?
+ *
+ * PGPA_TROVE_LOOKUP_SCAN means we're looking for scan advice.
+ *
+ * PGPA_TROVE_LOOKUP_JOIN means we're looking for join-related advice.
+ * This includes join order advice, join method advice, and semijoin-uniqueness
+ * advice.
+ *
+ * PGPA_TROVE_LOOKUP_REL means we're looking for general advice about this
+ * a RelOptInfo that may correspond to either a scan or a join. This includes
+ * gather-related advice and partitionwise advice. Note that partitionwise
+ * advice might seem like join advice, but that's not a helpful way of viewing
+ * the matter because (1) partitionwise advice is also relevant at the scan
+ * level and (2) other types of join advice affect only what to do from
+ * join_path_setup_hook, but partitionwise advice affects what to do in
+ * joinrel_setup_hook.
+ */
+typedef enum pgpa_trove_lookup_type
+{
+	PGPA_TROVE_LOOKUP_JOIN,
+	PGPA_TROVE_LOOKUP_REL,
+	PGPA_TROVE_LOOKUP_SCAN
+} pgpa_trove_lookup_type;
+
+/*
+ * This struct is used to store the result of a trove lookup. For each member
+ * of "indexes", the entry at the corresponding offset within "entries" is one
+ * of the results.
+ */
+typedef struct pgpa_trove_result
+{
+	pgpa_trove_entry *entries;
+	Bitmapset  *indexes;
+} pgpa_trove_result;
+
+extern pgpa_trove *pgpa_build_trove(List *advice_items);
+extern void pgpa_trove_lookup(pgpa_trove *trove,
+							  pgpa_trove_lookup_type type,
+							  int nrids,
+							  pgpa_identifier *rids,
+							  pgpa_trove_result *result);
+extern void pgpa_trove_lookup_all(pgpa_trove *trove,
+								  pgpa_trove_lookup_type type,
+								  pgpa_trove_entry **entries,
+								  int *nentries);
+extern char *pgpa_cstring_trove_entry(pgpa_trove_entry *entry);
+extern void pgpa_trove_set_flags(pgpa_trove_entry *entries,
+								 Bitmapset *indexes, int flags);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
new file mode 100644
index 00000000000..4ed22291096
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -0,0 +1,890 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.c
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/plannodes.h"
+#include "parser/parsetree.h"
+
+static void pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+								  bool within_join_problem,
+								  pgpa_join_unroller *join_unroller,
+								  List *active_query_features,
+								  bool beneath_any_gather);
+static Bitmapset *pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+											 pgpa_unrolled_join *ujoin);
+
+static pgpa_query_feature *pgpa_add_feature(pgpa_plan_walker_context *walker,
+											pgpa_qf_type type,
+											Plan *plan);
+
+static void pgpa_qf_add_rti(List *active_query_features, Index rti);
+static void pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids);
+static void pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan,
+								  List *rtable);
+
+static bool pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+										   Index rtable_length,
+										   pgpa_identifier *rt_identifiers,
+										   pgpa_advice_target *target,
+										   bool toplevel);
+static bool pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+												  Index rtable_length,
+												  pgpa_identifier *rt_identifiers,
+												  pgpa_advice_target *target);
+static bool pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+									  pgpa_scan_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+										 pgpa_qf_type type,
+										 Bitmapset *relids);
+static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+									  pgpa_join_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+										   Bitmapset *relids);
+static Index pgpa_walker_get_rti(Index rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid);
+
+/*
+ * Top-level entrypoint for the plan tree walk.
+ *
+ * Populates walker based on a traversal of the Plan trees in pstmt.
+ */
+void
+pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt)
+{
+	ListCell   *lc;
+
+	/* Initialization. */
+	memset(walker, 0, sizeof(pgpa_plan_walker_context));
+	walker->pstmt = pstmt;
+
+	/* Walk the main plan tree. */
+	pgpa_walk_recursively(walker, pstmt->planTree, 0, NULL, NIL, false);
+
+	/* Main plan tree walk won't reach subplans, so walk those. */
+	foreach(lc, pstmt->subplans)
+	{
+		Plan	   *plan = lfirst(lc);
+
+		if (plan != NULL)
+			pgpa_walk_recursively(walker, plan, 0, NULL, NIL, false);
+	}
+}
+
+/*
+ * Main workhorse for the plan tree walk.
+ *
+ * If within_join_problem is true, we encountered a join at some higher level
+ * of the tree walk and haven't yet descended out of the portion of the plan
+ * tree that is part of that same join problem. We're no longer in the same
+ * join problem if (1) we cross into a different subquery or (2) we descend
+ * through an Append or MergeAppend node, below which any further joins would
+ * be partitionwise joins planned separately from the outer join problem.
+ *
+ * If join_unroller != NULL, the join unroller code expects us to find a join
+ * that should be unrolled into that object. This implies that we're within a
+ * join problem, but the reverse is not true: when we've traversed all the
+ * joins but are still looking for the scan that is the leaf of the join tree,
+ * join_unroller will be NULL but within_join_problem will be true.
+ *
+ * Each element of active_query_features corresponds to some item of advice
+ * that needs to enumerate all the relations it affects. We add RTIs we find
+ * during tree traversal to each of these query features.
+ *
+ * If beneath_any_gather == true, some higher level of the tree traversal found
+ * a Gather or Gather Merge node.
+ */
+static void
+pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+					  bool within_join_problem,
+					  pgpa_join_unroller *join_unroller,
+					  List *active_query_features,
+					  bool beneath_any_gather)
+{
+	pgpa_join_unroller *outer_join_unroller = NULL;
+	pgpa_join_unroller *inner_join_unroller = NULL;
+	bool		join_unroller_toplevel = false;
+	List	   *pushdown_query_features = NIL;
+	ListCell   *lc;
+	List	   *extraplans = NIL;
+	List	   *elided_nodes = NIL;
+
+	Assert(within_join_problem || join_unroller == NULL);
+
+	/*
+	 * If this is a Gather or Gather Merge node, directly add it to the list
+	 * of currently-active query features.
+	 *
+	 * Otherwise, check the future_query_features list to see whether this was
+	 * previously identified as a plan node that needs to be treated as a
+	 * query feature.
+	 *
+	 * Note that the caller also has a copy to active_query_features, so we
+	 * can't destructively modify it without making a copy.
+	 */
+	if (IsA(plan, Gather))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER, plan));
+		beneath_any_gather = true;
+	}
+	else if (IsA(plan, GatherMerge))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER_MERGE, plan));
+		beneath_any_gather = true;
+	}
+	else
+	{
+		foreach_ptr(pgpa_query_feature, qf, walker->future_query_features)
+		{
+			if (qf->plan == plan)
+			{
+				active_query_features = list_copy(active_query_features);
+				active_query_features = lappend(active_query_features, qf);
+				walker->future_query_features =
+					list_delete_ptr(walker->future_query_features, plan);
+				break;
+			}
+		}
+	}
+
+	/*
+	 * Find all elided nodes for this Plan node.
+	 */
+	foreach_node(ElidedNode, n, walker->pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_nodes = lappend(elided_nodes, n);
+	}
+
+	/* If we found any elided_nodes, handle them. */
+	if (elided_nodes != NIL)
+	{
+		int			num_elided_nodes = list_length(elided_nodes);
+		ElidedNode *last_elided_node;
+
+		/*
+		 * RTIs for the final -- and thus logically uppermost -- elided node
+		 * should be collected for query features passed down by the caller.
+		 * However, elided nodes act as barriers to query features, which
+		 * means that (1) the remaining elided nodes, if any, should be
+		 * ignored for purposes of query features and (2) the list of active
+		 * query features should be reset to empty so that we do not add RTIs
+		 * from the plan node that is logically beneath the elided node to the
+		 * query features passed down from the caller.
+		 */
+		last_elided_node = list_nth(elided_nodes, num_elided_nodes - 1);
+		pgpa_qf_add_rtis(active_query_features,
+						 pgpa_filter_out_join_relids(last_elided_node->relids,
+													 walker->pstmt->rtable));
+		active_query_features = NIL;
+
+		/*
+		 * If we're within a join problem, the join_unroller is responsible
+		 * for building the scan for the final elided node, so throw it out.
+		 */
+		if (within_join_problem)
+			elided_nodes = list_truncate(elided_nodes, num_elided_nodes - 1);
+
+		/* Build scans for all (or the remaining) elided nodes. */
+		foreach_node(ElidedNode, elided_node, elided_nodes)
+		{
+			(void) pgpa_build_scan(walker, plan, elided_node,
+								   beneath_any_gather, within_join_problem);
+		}
+
+		/*
+		 * If there were any elided nodes, then everything beneath those nodes
+		 * is not part of the same join problem.
+		 *
+		 * In more detail, if an Append or MergeAppend was elided, then a
+		 * partitionwise join was chosen and only a single child survived; if
+		 * a SubqueryScan was elided, the subquery was planned without
+		 * flattening it into the parent.
+		 */
+		within_join_problem = false;
+		join_unroller = NULL;
+	}
+
+	/*
+	 * If we're within a join problem, the join unroller is responsible for
+	 * building any required scan for this node. If not, we do it here.
+	 */
+	if (!within_join_problem)
+		(void) pgpa_build_scan(walker, plan, NULL, beneath_any_gather, false);
+
+	/*
+	 * If this join needs to unrolled but there's no join unroller already
+	 * available, create one.
+	 */
+	if (join_unroller == NULL && pgpa_is_join(plan))
+	{
+		join_unroller = pgpa_create_join_unroller();
+		join_unroller_toplevel = true;
+		within_join_problem = true;
+	}
+
+	/*
+	 * If this join is to be unrolled, pgpa_unroll_join() will return the join
+	 * unroller object that should be passed down when we recurse into the
+	 * outer and inner sides of the plan.
+	 */
+	if (join_unroller != NULL)
+		pgpa_unroll_join(walker, plan, beneath_any_gather, join_unroller,
+						 &outer_join_unroller, &inner_join_unroller);
+
+	/* Add RTIs from the plan node to all active query features. */
+	pgpa_qf_add_plan_rtis(active_query_features, plan, walker->pstmt->rtable);
+
+	/*
+	 * Recurse into the outer and inner subtrees.
+	 *
+	 * As an exception, if this is a ForeignScan, don't recurse. postgres_fdw
+	 * sometimes stores an EPQ recheck plan in plan->leftree, but that's going
+	 * to mention the same set of relations as the ForeignScan itself, and we
+	 * have no way to emit advice targeting the EPQ case vs. the non-EPQ case.
+	 * Moreover, it's not entirely clear what other FDWs might do with the
+	 * left and right subtrees. Maybe some better handling is needed here, but
+	 * for now, we just punt.
+	 */
+	if (!IsA(plan, ForeignScan))
+	{
+		if (plan->lefttree != NULL)
+			pgpa_walk_recursively(walker, plan->lefttree, within_join_problem,
+								  outer_join_unroller, active_query_features,
+								  beneath_any_gather);
+		if (plan->righttree != NULL)
+			pgpa_walk_recursively(walker, plan->righttree, within_join_problem,
+								  inner_join_unroller, active_query_features,
+								  beneath_any_gather);
+	}
+
+	/*
+	 * If we created a join unroller up above, then it's also our join to use
+	 * it to build the final pgpa_unrolled_join, and to destroy the object.
+	 */
+	if (join_unroller_toplevel)
+	{
+		pgpa_unrolled_join *ujoin;
+
+		ujoin = pgpa_build_unrolled_join(walker, join_unroller);
+		walker->toplevel_unrolled_joins =
+			lappend(walker->toplevel_unrolled_joins, ujoin);
+		pgpa_destroy_join_unroller(join_unroller);
+		(void) pgpa_process_unrolled_join(walker, ujoin);
+	}
+
+	/*
+	 * Some plan types can have additional children. Nodes like Append that
+	 * can have any number of children store them in a List; a SubqueryScan
+	 * just has a field for a single additional Plan.
+	 */
+	switch (nodeTag(plan))
+	{
+		case T_Append:
+			{
+				Append	   *aplan = (Append *) plan;
+
+				extraplans = aplan->appendplans;
+				if (bms_is_empty(aplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_MergeAppend:
+			{
+				MergeAppend *maplan = (MergeAppend *) plan;
+
+				extraplans = maplan->mergeplans;
+				if (bms_is_empty(maplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_BitmapAnd:
+			extraplans = ((BitmapAnd *) plan)->bitmapplans;
+			break;
+		case T_BitmapOr:
+			extraplans = ((BitmapOr *) plan)->bitmapplans;
+			break;
+		case T_SubqueryScan:
+
+			/*
+			 * We don't pass down active_query_features across here, because
+			 * those are specific to a subquery level.
+			 */
+			pgpa_walk_recursively(walker, ((SubqueryScan *) plan)->subplan,
+								  0, NULL, NIL, beneath_any_gather);
+			break;
+		case T_CustomScan:
+			extraplans = ((CustomScan *) plan)->custom_plans;
+			break;
+		default:
+			break;
+	}
+
+	/* If we found a list of extra children, iterate over it. */
+	foreach(lc, extraplans)
+	{
+		Plan	   *subplan = lfirst(lc);
+
+		pgpa_walk_recursively(walker, subplan, 0, NULL, pushdown_query_features,
+							  beneath_any_gather);
+	}
+}
+
+/*
+ * Perform final processing of a newly-constructed pgpa_unrolled_join. This
+ * only needs to be called for toplevel pgpa_unrolled_join objects, since it
+ * recurses to sub-joins as needed.
+ *
+ * Our goal is to add the set of inner relids to the relevant join_strategies
+ * list, and to do the same for any sub-joins. To that end, the return value
+ * is the set of relids found beneath the inner side of the join, but it is
+ * expected that the toplevel caller will ignore this.
+ */
+static Bitmapset *
+pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+						   pgpa_unrolled_join *ujoin)
+{
+	Bitmapset  *all_relids = NULL;
+
+	for (int k = 0; k < ujoin->ninner; ++k)
+	{
+		pgpa_join_member *member = &ujoin->inner[k];
+		Bitmapset  *relids;
+
+		if (member->unrolled_join != NULL)
+			relids = pgpa_process_unrolled_join(walker,
+												member->unrolled_join);
+		else
+		{
+			Assert(member->scan != NULL);
+			relids = member->scan->relids;
+		}
+		walker->join_strategies[ujoin->strategy[k]] =
+			lappend(walker->join_strategies[ujoin->strategy[k]], relids);
+		all_relids = bms_add_members(all_relids, relids);
+	}
+
+	return all_relids;
+}
+
+/*
+ * Arrange for the given plan node to be treated as a query feature when the
+ * tree walk reaches it.
+ *
+ * Make sure to only use this for nodes that the tree walk can't have reached
+ * yet!
+ */
+void
+pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+						pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = pgpa_add_feature(walker, type, plan);
+
+	walker->future_query_features =
+		lappend(walker->future_query_features, qf);
+}
+
+/*
+ * Return the last of any elided nodes associated with this plan node ID.
+ *
+ * The last elided node is the one that would have been uppermost in the plan
+ * tree had it not been removed during setrefs processig.
+ */
+ElidedNode *
+pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan)
+{
+	ElidedNode *elided_node = NULL;
+
+	foreach_node(ElidedNode, n, pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_node = n;
+	}
+
+	return elided_node;
+}
+
+/*
+ * Certain plan nodes can refer to a set of RTIs. Extract and return the set.
+ */
+Bitmapset *
+pgpa_relids(Plan *plan)
+{
+	if (IsA(plan, Result))
+		return ((Result *) plan)->relids;
+	else if (IsA(plan, ForeignScan))
+		return ((ForeignScan *) plan)->fs_relids;
+	else if (IsA(plan, Append))
+		return ((Append *) plan)->apprelids;
+	else if (IsA(plan, MergeAppend))
+		return ((MergeAppend *) plan)->apprelids;
+
+	return NULL;
+}
+
+/*
+ * Extract the scanned RTI from a plan node.
+ *
+ * Returns 0 if there isn't one.
+ */
+Index
+pgpa_scanrelid(Plan *plan)
+{
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+		case T_SubqueryScan:
+		case T_FunctionScan:
+		case T_TableFuncScan:
+		case T_ValuesScan:
+		case T_CteScan:
+		case T_NamedTuplestoreScan:
+		case T_WorkTableScan:
+		case T_ForeignScan:
+		case T_CustomScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+			return ((Scan *) plan)->scanrelid;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
+ */
+Bitmapset *
+pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	Bitmapset  *result = NULL;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind != RTE_JOIN)
+			result = bms_add_member(result, rti);
+	}
+
+	return result;
+}
+
+/*
+ * Create a pgpa_query_feature and add it to the list of all query features
+ * for this plan.
+ */
+static pgpa_query_feature *
+pgpa_add_feature(pgpa_plan_walker_context *walker,
+				 pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = palloc0_object(pgpa_query_feature);
+
+	qf->type = type;
+	qf->plan = plan;
+
+	walker->query_features[qf->type] =
+		lappend(walker->query_features[qf->type], qf);
+
+	return qf;
+}
+
+/*
+ * Add a single RTI to each active query feature.
+ */
+static void
+pgpa_qf_add_rti(List *active_query_features, Index rti)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_member(qf->relids, rti);
+	}
+}
+
+/*
+ * Add a set of RTIs to each active query feature.
+ */
+static void
+pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_members(qf->relids, relids);
+	}
+}
+
+/*
+ * Add RTIs directly contained in a plan node to each active query feature,
+ * but filter out any join RTIs, since advice doesn't mention those.
+ */
+static void
+pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan, List *rtable)
+{
+	Bitmapset  *relids;
+	Index		rti;
+
+	if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		relids = pgpa_filter_out_join_relids(relids, rtable);
+		pgpa_qf_add_rtis(active_query_features, relids);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+		pgpa_qf_add_rti(active_query_features, rti);
+}
+
+/*
+ * If we generated plan advice using the provided walker object and array
+ * of identifiers, would we generate the specified tag/target combination?
+ *
+ * If yes, the plan conforms to the advice; if no, it does not. Note that
+ * we have know way of knowing whether the planner was forced to emit a plan
+ * that conformed to the advice or just happened to do so.
+ */
+bool
+pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+						 pgpa_identifier *rt_identifiers,
+						 pgpa_advice_tag_type tag,
+						 pgpa_advice_target *target)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	Bitmapset  *relids = NULL;
+
+	if (tag == PGPA_TAG_JOIN_ORDER)
+	{
+		foreach_ptr(pgpa_unrolled_join, ujoin, walker->toplevel_unrolled_joins)
+		{
+			if (pgpa_walker_join_order_matches(ujoin, rtable_length,
+											   rt_identifiers, target, true))
+				return true;
+		}
+
+		return false;
+	}
+
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		Index		rti;
+
+		rti = pgpa_walker_get_rti(rtable_length, rt_identifiers, &target->rid);
+		relids = bms_make_singleton(rti);
+	}
+	else
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			Index		rti;
+
+			Assert(child_target->ttype == PGPA_TARGET_IDENTIFIER);
+			rti = pgpa_compute_rti_from_identifier(rtable_length,
+												   rt_identifiers,
+												   &child_target->rid);
+			if (rti == 0)
+				elog(ERROR, "cannot determine RTI for advice target");
+			relids = bms_add_member(relids, rti);
+		}
+	}
+
+	switch (tag)
+	{
+		case PGPA_TAG_JOIN_ORDER:
+			/* should have been handled above */
+			pg_unreachable();
+			break;
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_BITMAP_HEAP,
+											 relids);
+		case PGPA_TAG_FOREIGN_JOIN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_FOREIGN,
+											 relids);
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX_ONLY,
+											 relids);
+		case PGPA_TAG_INDEX_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX,
+											 relids);
+		case PGPA_TAG_PARTITIONWISE:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_PARTITIONWISE,
+											 relids);
+		case PGPA_TAG_SEQ_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_SEQ,
+											 relids);
+		case PGPA_TAG_TID_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_TID,
+											 relids);
+		case PGPA_TAG_GATHER:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER,
+												relids);
+		case PGPA_TAG_GATHER_MERGE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER_MERGE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_NON_UNIQUE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_UNIQUE,
+												relids);
+		case PGPA_TAG_HASH_JOIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_HASH_JOIN,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_PLAIN,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MEMOIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_PLAIN,
+											 relids);
+		case PGPA_TAG_NO_GATHER:
+			return pgpa_walker_contains_no_gather(walker, relids);
+	}
+
+	/* should not get here */
+	return false;
+}
+
+/*
+ * Does an unrolled join match the join order specified by an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+							   Index rtable_length,
+							   pgpa_identifier *rt_identifiers,
+							   pgpa_advice_target *target,
+							   bool toplevel)
+{
+	int		nchildren = list_length(target->children);
+
+	Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	/* At toplevel, we allow a prefix match. */
+	if (toplevel)
+	{
+		if (nchildren > ujoin->ninner + 1)
+			return false;
+	}
+	else
+	{
+		if (nchildren != ujoin->ninner + 1)
+			return false;
+	}
+
+	/* Outermost rel must match. */
+	if (!pgpa_walker_join_order_matches_member(&ujoin->outer,
+											   rtable_length,
+											   rt_identifiers,
+											   linitial(target->children)))
+		return false;
+
+	/* Each inner rel must match. */
+	for (int n = 0; n < nchildren - 1; ++n)
+	{
+		pgpa_advice_target *child_target = list_nth(target->children, n + 1);
+
+		if (!pgpa_walker_join_order_matches_member(&ujoin->inner[n],
+												   rtable_length,
+												   rt_identifiers,
+												   child_target))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Does one member of an unrolled join match an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+									  Index rtable_length,
+									  pgpa_identifier *rt_identifiers,
+									  pgpa_advice_target *target)
+{
+	Bitmapset  *relids = NULL;
+
+	if (member->unrolled_join != NULL)
+	{
+		if (target->ttype != PGPA_TARGET_ORDERED_LIST)
+			return false;
+		return pgpa_walker_join_order_matches(member->unrolled_join,
+											  rtable_length,
+											  rt_identifiers,
+											  target,
+											  false);
+	}
+
+	Assert(member->scan != NULL);
+	switch (target->ttype)
+	{
+		case PGPA_TARGET_ORDERED_LIST:
+			/* Could only match an unrolled join */
+			return false;
+
+		case PGPA_TARGET_UNORDERED_LIST:
+			{
+				foreach_ptr(pgpa_advice_target, child_target, target->children)
+				{
+					Index		rti;
+
+					rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+											  &child_target->rid);
+					relids = bms_add_member(relids, rti);
+				}
+				break;
+			}
+
+		case PGPA_TARGET_IDENTIFIER:
+			{
+				Index		rti;
+
+				rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+										  &target->rid);
+				relids = bms_make_singleton(rti);
+				break;
+			}
+	}
+
+	return bms_equal(member->scan->relids, relids);
+}
+
+/*
+ * Does this walker say that the given scan strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+						  pgpa_scan_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *scans = walker->scans[strategy];
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		/*
+		 * XXX. If this is index-related advice, we should also validate that
+		 * the advice target's index target matches the Plan tree.
+		 */
+		if (bms_equal(scan->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does this walker say that the given query feature applies to the given
+ * relid set?
+ */
+static bool
+pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+							 pgpa_qf_type type,
+							 Bitmapset *relids)
+{
+	List	   *query_features = walker->query_features[type];
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (bms_equal(qf->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given join strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+						  pgpa_join_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *join_strategies = walker->join_strategies[strategy];
+
+	foreach_ptr(Bitmapset, jsrelids, join_strategies)
+	{
+		if (bms_equal(jsrelids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given relids should be marked as NO_GATHER?
+ */
+static bool
+pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+							   Bitmapset *relids)
+{
+	return bms_is_subset(relids, walker->no_gather_scans);
+}
+
+/*
+ * Convenience function to convert a relation identifier to an RTI.
+ *
+ * We throw an error here because we expect this to be used on system-generated
+ * advice. Hence, failure here indicates an advice generation bug.
+ */
+static Index
+pgpa_walker_get_rti(Index rtable_length,
+					pgpa_identifier *rt_identifiers,
+					pgpa_identifier *rid)
+{
+	Index		rti;
+
+	rti = pgpa_compute_rti_from_identifier(rtable_length,
+										   rt_identifiers,
+										   rid);
+	if (rti == 0)
+		elog(ERROR, "cannot determine RTI for advice target");
+	return rti;
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
new file mode 100644
index 00000000000..f244f4428a5
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -0,0 +1,122 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.h
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_WALKER_H
+#define PGPA_WALKER_H
+
+#include "pgpa_ast.h"
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+
+/*
+ * We use the term "query feature" to refer to plan nodes that are interesting
+ * in the following way: to generate advice, we'll need to know the set of
+ * same-subquery, non-join RTIs occuring at or below that plan node, without
+ * admixture of parent and child RTIs.
+ *
+ * For example, Gather nodes, desiginated by PGPAQF_GATHER, and Gather Merge
+ * nodes, designated by PGPAQF_GATHER_MERGE, are query features, because we'll
+ * want to admit some kind of advice that describes the portion of the plan
+ * tree that appears beneath those nodes.
+ *
+ * Each semijoin can be implemented either by directly performing a semijoin,
+ * or by making one side unique and then performing a normal join. Either way,
+ * we use a query feature to notice what decision was made, so that we can
+ * describe it by enumerating the RTIs on that side of the join.
+ *
+ * To elaborate on the "no admixture of parent and child RTIs" rule, in all of
+ * these cases, if the entirety of an inheritance hierarchy appears beneath
+ * the query feature, we only want to name the parent table. But it's also
+ * possible to have cases where we must name child tables. This is particularly
+ * likely to happen when partitionwise join is in use, but could happen for
+ * Gather or Gather Merge even without that, if one of those appears below
+ * an Append or MergeAppend node for a single table.
+ */
+typedef enum pgpa_qf_type
+{
+	PGPAQF_GATHER,
+	PGPAQF_GATHER_MERGE,
+	PGPAQF_SEMIJOIN_NON_UNIQUE,
+	PGPAQF_SEMIJOIN_UNIQUE
+	/* update NUM_PGPA_QF_TYPES if you add anything here */
+} pgpa_qf_type;
+
+#define NUM_PGPA_QF_TYPES ((int) PGPAQF_SEMIJOIN_UNIQUE + 1)
+
+/*
+ * For each query feature, we keep track of the feature type and the set of
+ * relids that we found underneath the relevant plan node. See the comments
+ * on pgpa_qf_type, above, for additional details.
+ */
+typedef struct pgpa_query_feature
+{
+	pgpa_qf_type type;
+	Plan	   *plan;
+	Bitmapset  *relids;
+} pgpa_query_feature;
+
+/*
+ * Context object for plan tree walk.
+ *
+ * pstmt is the PlannedStmt we're studying.
+ *
+ * scans is an array of lists of pgpa_scan objects. The array is indexed by
+ * the scan's pgpa_scan_strategy.
+ *
+ * no_gather_scans is the set of scan RTIs that do not appear beneath any
+ * Gather or Gather Merge node.
+ *
+ * toplevel_unrolled_joins is a list of all pgpa_unrolled_join objects that
+ * are not a child of some other pgpa_unrolled_join.
+ *
+ * join_strategy is an array of lists of Bitmapset objects. Each Bitmapset
+ * is the set of relids that appears on the inner side of some join (excluding
+ * RTIs from partition children and subqueries). The array is indexed by
+ * pgpa_join_strategy.
+ *
+ * query_features is an array lists of pgpa_query_feature objects, indexed
+ * by pgpa_qf_type.
+ *
+ * future_query_features is only used during the plan tree walk and should
+ * be empty when the tree walk concludes. It is a list of pgpa_query_feature
+ * objects for Plan nodes that the plan tree walk has not yet encountered;
+ * when encountered, they will be moved to the list of active query features
+ * that is propagated via the call stack.
+ */
+typedef struct pgpa_plan_walker_context
+{
+	PlannedStmt *pstmt;
+	List	   *scans[NUM_PGPA_SCAN_STRATEGY];
+	Bitmapset  *no_gather_scans;
+	List	   *toplevel_unrolled_joins;
+	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
+	List	   *query_features[NUM_PGPA_QF_TYPES];
+	List	   *future_query_features;
+} pgpa_plan_walker_context;
+
+extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
+							 PlannedStmt *pstmt);
+
+extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+									pgpa_qf_type type,
+									Plan *plan);
+
+extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
+extern Bitmapset *pgpa_relids(Plan *plan);
+extern Index pgpa_scanrelid(Plan *plan);
+extern Bitmapset *pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable);
+
+extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+									 pgpa_identifier *rt_identifiers,
+									 pgpa_advice_tag_type tag,
+									 pgpa_advice_target *target);
+
+#endif
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
new file mode 100644
index 00000000000..58280043913
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_order.sql b/contrib/pg_plan_advice/sql/join_order.sql
new file mode 100644
index 00000000000..5aa2fc62d34
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_order.sql
@@ -0,0 +1,96 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
+
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/sql/join_strategy.sql b/contrib/pg_plan_advice/sql/join_strategy.sql
new file mode 100644
index 00000000000..8eb823f1c0e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_strategy.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/local_collector.sql b/contrib/pg_plan_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..fc838b2204d
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/local_collector.sql
@@ -0,0 +1,41 @@
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_plan_advice/sql/partitionwise.sql b/contrib/pg_plan_advice/sql/partitionwise.sql
new file mode 100644
index 00000000000..e42c0611760
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/partitionwise.sql
@@ -0,0 +1,78 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
new file mode 100644
index 00000000000..25416a75f46
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -0,0 +1,195 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+COMMIT;
+
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+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 > 0;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+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;
+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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+COMMIT;
+
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/syntax.sql b/contrib/pg_plan_advice/sql/syntax.sql
new file mode 100644
index 00000000000..8bc1b71bebe
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/syntax.sql
@@ -0,0 +1,42 @@
+LOAD 'pg_plan_advice';
+
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+SET pg_plan_advice.advice = '()';
+SET pg_plan_advice.advice = '123';
+
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
diff --git a/contrib/pg_plan_advice/t/001_regress.pl b/contrib/pg_plan_advice/t/001_regress.pl
new file mode 100644
index 00000000000..735f54d57ec
--- /dev/null
+++ b/contrib/pg_plan_advice/t/001_regress.pl
@@ -0,0 +1,147 @@
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_plan_advice to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+#
+# We run with pg_plan_advice.shared_collection_limit set to ensure that the
+# plan tree walker code runs against every query in the regression tests. If
+# we're unable to properly analyze any of those plan trees, this test should fail.
+#
+# We set pg_plan_advice.advice to an advice string that will cause the advice
+# trove to be populated with a few entries of various sorts, but which we do
+# not expect to match anything in the regression test queries. This way, the
+# planner hooks will be called, improving code coverage, but no plans should
+# actually change.
+#
+# pg_plan_advice.always_explain_supplied_advice=false is needed to avoid breaking
+# regression test queries that use EXPLAIN. In the real world, it seems like
+# users will want EXPLAIN output to show supplied advice so that it's clear
+# whether normal planner behavior has been altered, but here that's undesirable.
+$node->append_conf('postgresql.conf', <<EOM);
+pg_plan_advice.shared_collection_limit=1000000
+shared_preload_libraries=pg_plan_advice
+pg_plan_advice.advice='SEQ_SCAN(entirely_fictitious) HASH_JOIN(total_fabrication) GATHER(completely_imaginary)'
+pg_plan_advice.always_explain_supplied_advice=false
+EOM
+$node->start;
+
+my $srcdir = abs_path("../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+# Create the extension so we can access the collector
+$node->safe_psql('postgres', 'CREATE EXTENSION pg_plan_advice');
+
+# Verify that a large amount of advice was collected
+my $all_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice();
+EOM
+cmp_ok($all_query_count, '>', 35000, "copious advice collected");
+
+# Verify that lots of different advice strings were collected
+my $distinct_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM
+	(SELECT DISTINCT advice FROM pg_get_collected_shared_advice());
+EOM
+cmp_ok($distinct_query_count, '>', 3000, "diverse advice collected");
+
+# We want to test for the presence of our known tags in the collected advice.
+# Put all tags into the hash that follows; map any tags that aren't tested
+# by the core regression tests to 0, and others to 1.
+my %tag_map = (
+	BITMAP_HEAP_SCAN => 1,
+	FOREIGN_JOIN => 0,
+	GATHER => 1,
+	GATHER_MERGE => 1,
+	HASH_JOIN => 1,
+	INDEX_ONLY_SCAN => 1,
+	INDEX_SCAN => 1,
+	JOIN_ORDER => 1,
+	MERGE_JOIN_MATERIALIZE => 1,
+	MERGE_JOIN_PLAIN => 1,
+	NESTED_LOOP_MATERIALIZE => 1,
+	NESTED_LOOP_MEMOIZE => 1,
+	NESTED_LOOP_PLAIN => 1,
+	NO_GATHER => 1,
+	PARTITIONWISE => 1,
+	SEMIJOIN_NON_UNIQUE => 1,
+	SEMIJOIN_UNIQUE => 1,
+	SEQ_SCAN => 1,
+	TID_SCAN => 1,
+);
+for my $tag (sort keys %tag_map)
+{
+	my $checkit = $tag_map{$tag};
+
+	# Search for the given tag. This is not entirely robust: it could get thrown
+	# off by a table alias such as "FOREIGN_JOIN(", but that probably won't
+	# happen in the core regression tests.
+	my $tag_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice()
+	WHERE advice LIKE '%$tag(%'
+EOM
+
+	# Check that the tag got a non-trivial amount of use, unless told otherwise.
+	cmp_ok($tag_count, '>', 10, "multiple uses of $tag") if $checkit;
+
+	# Regardless, note the exact count in the log, for human consumption.
+	note("found $tag_count advice strings containing $tag");
+}
+
+# Trigger a partial cleanup of the shared advice collector, and then a full
+# cleanup.
+$node->safe_psql('postgres', <<EOM);
+SET pg_plan_advice.shared_collection_limit=500;
+SELECT * FROM pg_clear_collected_shared_advice();
+EOM
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 8b300ae8233..a8d1889cb10 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3946,6 +3946,43 @@ pg_wc_probefunc
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgpa_collected_advice
+pgpa_advice_item
+pgpa_advice_tag_type
+pgpa_advice_target
+pgpa_identifier
+pgpa_index_target
+pgpa_index_type
+pgpa_itm_type
+pgpa_join_class
+pgpa_join_member
+pgpa_join_state
+pgpa_join_strategy
+pgpa_join_unroller
+pgpa_local_advice
+pgpa_local_advice_chunk
+pgpa_output_context
+pgpa_plan_walker_context
+pgpa_planner_state
+pgpa_qf_type
+pgpa_query_feature
+pgpa_ri_checker
+pgpa_ri_checker_key
+pgpa_scan
+pgpa_scan_strategy
+pgpa_shared_advice
+pgpa_shared_advice_chunk
+pgpa_shared_state
+pgpa_target_type
+pgpa_trove
+pgpa_trove_entry
+pgpa_trove_entry_element
+pgpa_trove_entry_hash
+pgpa_trove_entry_key
+pgpa_trove_lookup_type
+pgpa_trove_result
+pgpa_trove_slice
+pgpa_unrolled_join
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-12 01:11  Jacob Champion <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Jacob Champion @ 2025-12-12 01:11 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Tue, Dec 9, 2025 at 11:46 AM Robert Haas <[email protected]> wrote:
> By the way, if your fuzzer can also
> produces some things to add contrib/pg_plan_advice/sql for cases like
> this, that would be quite helpful. Ideally I would have caught this
> with a manually-written test case, but obviously that didn't happen.

Sure! (They'll need to be golfed down.) Here are three entries that
hit the crash, each on its own line:

> join_order(qoe((nested_l oindex_scanp_plain))se(nested_loop_plain)nested_loo/_pseq_scanlain)
> join_order(qoe((nested_loop_plain))se(nested_loop_plain)nesemij/insted_loop_plain)
> gather(gather(gar(g/ther0))gtaher(gathethga))

Something the fuzzer really likes is zero-length identifiers ("").
Maybe that's by design, but I thought I'd mention it since the
standard lexer doesn't allow that and syntax.sql doesn't exercise it.

> > It doesn't know that area is guaranteed to be non-NULL, so it can't
> > prove that ca_pointer is initialized.
>
> I don't know what to do about that. I can understand why it might be
> unable to prove that, but I don't see an obvious way to change the
> code that would make life easier. I could add Assert(area != NULL)
> before the call to pgpa_make_collected_advice() if that helps.

With USE_ASSERT_CHECKING, that should help, but I'm not sure if it
does without. (I could have sworn there was a conversation about that
at some point but I can't remember any of the keywords.) Could also
just make a dummy assignment. Or tag pg_plan_advice_dsa_area() with
__attribute__((returns_nonnull)), but that's more portability work.

--Jacob





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-12 17:36  Robert Haas <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2025-12-12 17:36 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Dec 11, 2025 at 8:11 PM Jacob Champion
<[email protected]> wrote:
> Sure! (They'll need to be golfed down.) Here are three entries that
> hit the crash, each on its own line:
>
> > join_order(qoe((nested_l oindex_scanp_plain))se(nested_loop_plain)nested_loo/_pseq_scanlain)
> > join_order(qoe((nested_loop_plain))se(nested_loop_plain)nesemij/insted_loop_plain)
> > gather(gather(gar(g/ther0))gtaher(gathethga))

At least for me, setting pg_plan_advice.advice to any of these strings
does not provoke a crash. What I discovered after a bit of
experimentation is that you get the crash if you (a) set the string to
something like this and then (b) run an EXPLAIN. Turns out, I already
had a test in syntax.sql that is sufficient to provoke the crash, so,
locally, I've added 'EXPLAIN SELECT 1' after each test case in
syntax.sql that is expected to successfully alter the value of the
GUC.

> Something the fuzzer really likes is zero-length identifiers ("").
> Maybe that's by design, but I thought I'd mention it since the
> standard lexer doesn't allow that and syntax.sql doesn't exercise it.

That's not by design. I've added a matching error check locally.

> > > It doesn't know that area is guaranteed to be non-NULL, so it can't
> > > prove that ca_pointer is initialized.
> >
> > I don't know what to do about that. I can understand why it might be
> > unable to prove that, but I don't see an obvious way to change the
> > code that would make life easier. I could add Assert(area != NULL)
> > before the call to pgpa_make_collected_advice() if that helps.
>
> With USE_ASSERT_CHECKING, that should help, but I'm not sure if it
> does without. (I could have sworn there was a conversation about that
> at some point but I can't remember any of the keywords.) Could also
> just make a dummy assignment. Or tag pg_plan_advice_dsa_area() with
> __attribute__((returns_nonnull)), but that's more portability work.

As in initialize ca_pointer to InvalidDsaPointer?

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-12 18:09  Jacob Champion <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 2 replies; 133+ messages in thread

From: Jacob Champion @ 2025-12-12 18:09 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Dec 12, 2025 at 9:36 AM Robert Haas <[email protected]> wrote:
> At least for me, setting pg_plan_advice.advice to any of these strings
> does not provoke a crash. What I discovered after a bit of
> experimentation is that you get the crash if you (a) set the string to
> something like this and then (b) run an EXPLAIN.

Makes sense (this fuzzer was exercising pgpa_format_advice_target()).

> > With USE_ASSERT_CHECKING, that should help, but I'm not sure if it
> > does without. (I could have sworn there was a conversation about that
> > at some point but I can't remember any of the keywords.) Could also
> > just make a dummy assignment. Or tag pg_plan_advice_dsa_area() with
> > __attribute__((returns_nonnull)), but that's more portability work.
>
> As in initialize ca_pointer to InvalidDsaPointer?

Yeah.

Next bit of fuzzer feedback: I need the following diff in
pgpa_trove_add_to_hash() to avoid a crash when the hashtable starts to
fill up:

>     element = pgpa_trove_entry_insert(hash, key, &found);
> +   if (!found)
> +       element->indexes = NULL;
>     element->indexes = bms_add_member(element->indexes, index);

The advice string that triggered this is horrific, but I can send it
to you offline if you're morbidly curious. (I can spend time to
minimize it or I can get more fuzzer coverage, and I'd rather do the
latter right now :D)

--Jacob





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-15 06:30  Ajay Pal <[email protected]>
  parent: Jacob Champion <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Ajay Pal @ 2025-12-15 06:30 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>; Jacob Champion <[email protected]>

During further testing of the plan_advice patch's latest version, I
observed that the following query is generating a no_gather plan. This
specific plan structure is not being accepted by the query planner.

postgres=*# set local pg_plan_advice.advice='NO_GATHER("*RESULT*")';
SET
postgres=*# explain ( plan_advice) SELECT
CAST('99999999999999999999999999999999999999999999999999' AS NUMERIC);
                QUERY PLAN
-------------------------------------------
 Result  (cost=0.00..0.01 rows=1 width=32)
 Supplied Plan Advice:
   NO_GATHER("*RESULT*") /* not matched */
 Generated Plan Advice:
   NO_GATHER("*RESULT*")
(5 rows)

Thanks
Ajay

On Fri, Dec 12, 2025 at 11:40 PM Jacob Champion
<[email protected]> wrote:
>
> On Fri, Dec 12, 2025 at 9:36 AM Robert Haas <[email protected]> wrote:
> > At least for me, setting pg_plan_advice.advice to any of these strings
> > does not provoke a crash. What I discovered after a bit of
> > experimentation is that you get the crash if you (a) set the string to
> > something like this and then (b) run an EXPLAIN.
>
> Makes sense (this fuzzer was exercising pgpa_format_advice_target()).
>
> > > With USE_ASSERT_CHECKING, that should help, but I'm not sure if it
> > > does without. (I could have sworn there was a conversation about that
> > > at some point but I can't remember any of the keywords.) Could also
> > > just make a dummy assignment. Or tag pg_plan_advice_dsa_area() with
> > > __attribute__((returns_nonnull)), but that's more portability work.
> >
> > As in initialize ca_pointer to InvalidDsaPointer?
>
> Yeah.
>
> Next bit of fuzzer feedback: I need the following diff in
> pgpa_trove_add_to_hash() to avoid a crash when the hashtable starts to
> fill up:
>
> >     element = pgpa_trove_entry_insert(hash, key, &found);
> > +   if (!found)
> > +       element->indexes = NULL;
> >     element->indexes = bms_add_member(element->indexes, index);
>
> The advice string that triggered this is horrific, but I can send it
> to you offline if you're morbidly curious. (I can spend time to
> minimize it or I can get more fuzzer coverage, and I'd rather do the
> latter right now :D)
>
> --Jacob
>
>





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-15 16:37  Jacob Champion <[email protected]>
  parent: Jacob Champion <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Jacob Champion @ 2025-12-15 16:37 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Dec 12, 2025 at 10:09 AM Jacob Champion
<[email protected]> wrote:
> Next bit of fuzzer feedback:

And another bit, but this time I was able to minimize into a
regression case, attached.

This comment in pgpa_identifier_matches_target() seems to be incorrect:

>     /*
>      * The identifier must specify a schema, but the target may leave the
>      * schema NULL to match anything.
>      */

But I don't know whether that's because the assumption itself is
wrong, or because a layer above hasn't filtered something out before
getting to this point.

--Jacob

diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
index 58280043913..cb04ed5cf30 100644
--- a/contrib/pg_plan_advice/sql/gather.sql
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -38,6 +38,9 @@ SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
 SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((d d/d.d))';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
 COMMIT;


Attachments:

  [text/plain] regress-crash.diff.txt (729B, 2-regress-crash.diff.txt)
  download | inline diff:
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
index 58280043913..cb04ed5cf30 100644
--- a/contrib/pg_plan_advice/sql/gather.sql
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -38,6 +38,9 @@ SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
 SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((d d/d.d))';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
 COMMIT;


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-15 20:06  Robert Haas <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 3 replies; 133+ messages in thread

From: Robert Haas @ 2025-12-15 20:06 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

Here's v7.

In 0001, I removed "const" from a node's struct declaration, because
Tom gave me some feedback to avoid that on another recent patch, and I
noticed I had done it here also. 0002, 0003, and 0004 are unchanged.

In 0005:

- Refactored the code to avoid issuing SEMIJOIN_NON_UNIQUE() advice in
cases where uniqueness wasn't actually considered.
- Adjusted the code not to issue NO_GATHER() advice for non-relation
RTEs. (This is the issue reported by Ajay Pal in a recent message to
this thread, which was also mentioned in an XXX in the code.)
- Reject zero-length delimited identifiers, per Jacob's email.
- Properly initialize element->indexes in pgpa_trove_add_to_hash, per
Jacob'e email.
- Add gather((d d/d.d)) test case, per Jacob, and fix the related bug
in pgpa_identifier_matches_target, per Jacob's email.
- Add EXPLAIN SELECT 1 after various test cases in syntax.sql, to
improve test coverage, per analysis of why the existing test case
didn't catch a bug previously reported by Jacob.
- Added a dummy initialization to pgpa_collector.c to placate nervous
compilers, per discussion with Jacob.

I think this mostly catches me up on responding to issues reported
here, although there is one thing reported to me off-list that I
haven't dealt with yet. If there's anything reported on thread that
isn't addressed here, let me know.

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v7-0001-Store-information-about-range-table-flattening-in.patch (7.9K, 2-v7-0001-Store-information-about-range-table-flattening-in.patch)
  download | inline diff:
From 2b6d59f9ff3e578718427ea6756208e3c5de3b58 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 12:00:18 -0400
Subject: [PATCH v7 1/5] Store information about range-table flattening in the
 final plan.

Suppose that we're currently planning a query and, when that same
query was previously planned and executed, we learned something about
how a certain table within that query should be planned. We want to
take note when that same table is being planned during the current
planning cycle, but this is difficult to do, because the RTI of the
table from the previous plan won't necessarily be equal to the RTI
that we see during the current planning cycle. This is because each
subquery has a separate range table during planning, but these are
flattened into one range table when constructing the final plan,
changing RTIs.

Commit 8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0 allows us to match up
subqueries seen in the previous planning cycles with the subqueries
currently being planned just by comparing textual names, but that's
not quite enough to let us deduce anything about individual tables,
because we don't know where each subquery's range table appears in
the final, flattened range table.

To fix that, store a list of SubPlanRTInfo objects in the final
planned statement, each including the name of the subplan, the offset
at which it begins in the flattened range table, and whether or not
it was a dummy subplan -- if it was, some RTIs may have been dropped
from the final range table, but also there's no need to control how
a dummy subquery gets planned. The toplevel subquery has no name and
always begins at rtoffset 0, so we make no entry for it.

This commit teaches pg_overexplain's RANGE_TABLE option to make use
of this new data to display the subquery name for each range table
entry.

NOTE TO REVIEWERS: If there's a clean way to make pg_overexplain display
this information without the new infrastructure provided by this patch,
then this patch is unnecessary. I thought there would be a way to do
that, but I couldn't figure anything out: there seems to be nothing that
records in the final PlannedStmt where subquery's range table ends and
the next one begins. In practice, one could usually figure it out by
matching up tables by relation OID, but that's neither clean nor
theoretically sound.
---
 contrib/pg_overexplain/pg_overexplain.c | 36 +++++++++++++++++++++++++
 src/backend/optimizer/plan/planner.c    |  1 +
 src/backend/optimizer/plan/setrefs.c    | 20 ++++++++++++++
 src/include/nodes/pathnodes.h           |  3 +++
 src/include/nodes/plannodes.h           | 17 ++++++++++++
 src/tools/pgindent/typedefs.list        |  1 +
 6 files changed, 78 insertions(+)

diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index fcdc17012da..1c4c796adb2 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -395,6 +395,8 @@ static void
 overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 {
 	Index		rti;
+	ListCell   *lc_subrtinfo = list_head(plannedstmt->subrtinfos);
+	SubPlanRTInfo *rtinfo = NULL;
 
 	/* Open group, one entry per RangeTblEntry */
 	ExplainOpenGroup("Range Table", "Range Table", false, es);
@@ -405,6 +407,18 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 		RangeTblEntry *rte = rt_fetch(rti, plannedstmt->rtable);
 		char	   *kind = NULL;
 		char	   *relkind;
+		SubPlanRTInfo *next_rtinfo;
+
+		/* Advance to next SubRTInfo, if it's time. */
+		if (lc_subrtinfo != NULL)
+		{
+			next_rtinfo = lfirst(lc_subrtinfo);
+			if (rti > next_rtinfo->rtoffset)
+			{
+				rtinfo = next_rtinfo;
+				lc_subrtinfo = lnext(plannedstmt->subrtinfos, lc_subrtinfo);
+			}
+		}
 
 		/* NULL entries are possible; skip them */
 		if (rte == NULL)
@@ -469,6 +483,28 @@ overexplain_range_table(PlannedStmt *plannedstmt, ExplainState *es)
 			ExplainPropertyBool("In From Clause", rte->inFromCl, es);
 		}
 
+		/*
+		 * Indicate which subplan is the origin of which RTE. Note dummy
+		 * subplans. Here again, we crunch more onto one line in text format.
+		 */
+		if (rtinfo != NULL)
+		{
+			if (es->format == EXPLAIN_FORMAT_TEXT)
+			{
+				if (!rtinfo->dummy)
+					ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				else
+					ExplainPropertyText("Subplan",
+										psprintf("%s (dummy)",
+												 rtinfo->plan_name), es);
+			}
+			else
+			{
+				ExplainPropertyText("Subplan", rtinfo->plan_name, es);
+				ExplainPropertyBool("Subplan Is Dummy", rtinfo->dummy, es);
+			}
+		}
+
 		/* rte->alias is optional; rte->eref is requested */
 		if (rte->alias != NULL)
 			overexplain_alias("Alias", rte->alias, es);
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 8b22c30559b..31dcbdf3422 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -607,6 +607,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->unprunableRelids = bms_difference(glob->allRelids,
 											  glob->prunableRelids);
 	result->permInfos = glob->finalrteperminfos;
+	result->subrtinfos = glob->subrtinfos;
 	result->resultRelations = glob->resultRelations;
 	result->appendRelations = glob->appendRelations;
 	result->subplans = glob->subplans;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index cd7ea1e6b58..edcd4aaa53e 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -399,6 +399,26 @@ add_rtes_to_flat_rtable(PlannerInfo *root, bool recursing)
 	Index		rti;
 	ListCell   *lc;
 
+	/*
+	 * Record enough information to make it possible for code that looks at
+	 * the final range table to understand how it was constructed. (If
+	 * finalrtable is still NIL, then this is the very topmost PlannerInfo,
+	 * which will always have plan_name == NULL and rtoffset == 0; we omit the
+	 * degenerate list entry.)
+	 */
+	if (root->glob->finalrtable != NIL)
+	{
+		SubPlanRTInfo *rtinfo = makeNode(SubPlanRTInfo);
+
+		rtinfo->plan_name = root->plan_name;
+		rtinfo->rtoffset = list_length(root->glob->finalrtable);
+
+		/* When recursing = true, it's an unplanned or dummy subquery. */
+		rtinfo->dummy = recursing;
+
+		root->glob->subrtinfos = lappend(root->glob->subrtinfos, rtinfo);
+	}
+
 	/*
 	 * Add the query's own RTEs to the flattened rangetable.
 	 *
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index b5ff456ef7f..4b892414d58 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -135,6 +135,9 @@ typedef struct PlannerGlobal
 	/* "flat" list of RTEPermissionInfos */
 	List	   *finalrteperminfos;
 
+	/* list of SubPlanRTInfo nodes */
+	List	   *subrtinfos;
+
 	/* "flat" list of PlanRowMarks */
 	List	   *finalrowmarks;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index c4393a94321..48331893a63 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -131,6 +131,9 @@ typedef struct PlannedStmt
 	 */
 	List	   *subplans;
 
+	/* a list of SubPlanRTInfo objects */
+	List	   *subrtinfos;
+
 	/* indices of subplans that require REWIND */
 	Bitmapset  *rewindPlanIDs;
 
@@ -1821,4 +1824,18 @@ typedef enum MonotonicFunction
 	MONOTONICFUNC_BOTH = MONOTONICFUNC_INCREASING | MONOTONICFUNC_DECREASING,
 } MonotonicFunction;
 
+/*
+ * SubPlanRTInfo
+ *
+ * Information about which range table entries came from which subquery
+ * planning cycles.
+ */
+typedef struct SubPlanRTInfo
+{
+	NodeTag		type;
+	char	   *plan_name;
+	Index		rtoffset;
+	bool		dummy;
+} SubPlanRTInfo;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3451538565e..14c01f80b22 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2918,6 +2918,7 @@ SubLink
 SubLinkType
 SubOpts
 SubPlan
+SubPlanRTInfo
 SubPlanState
 SubRelInfo
 SubRemoveRels
-- 
2.51.0



  [application/octet-stream] v7-0003-Store-information-about-Append-node-consolidation.patch (27.0K, 3-v7-0003-Store-information-about-Append-node-consolidation.patch)
  download | inline diff:
From 9c371e4e45741e0bd90675b69535efa1ef856e29 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:07 -0400
Subject: [PATCH v7 3/5] Store information about Append node consolidation in
 the final plan.

An extension (or core code) might want to reconstruct the planner's
decisions about whether and where to perform partitionwise joins from
the final plan. To do so, it must be possible to find all of the RTIs
of partitioned tables appearing in the plan. But when an AppendPath
or MergeAppendPath pulls up child paths from a subordinate AppendPath
or MergeAppendPath, the RTIs of the subordinate path do not appear
in the final plan, making this kind of reconstruction impossible.

To avoid this, propagate the RTI sets that would have been present
in the 'apprelids' field of the subordinate Append or MergeAppend
nodes that would have been created into the surviving Append or
MergeAppend node, using a new 'child_append_relid_sets' field for
that purpose. The value of this field is a list of Bitmapsets,
because each relation whose append-list was pulled up had its own
set of RTIs: just one, if it was a partitionwise scan, or more than
one, if it was a partitionwise join. Since our goal is to see where
partitionwise joins were done, it is essential to avoid losing the
information about how the RTIs were grouped in the pulled-up
relations.

This commit also updates pg_overexplain so that EXPLAIN (RANGE_TABLE)
will display the saved RTI sets.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 56 +++++++++++
 src/backend/optimizer/path/allpaths.c         | 98 +++++++++++++++----
 src/backend/optimizer/path/joinrels.c         |  2 +-
 src/backend/optimizer/plan/createplan.c       |  2 +
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/prep/prepunion.c        | 11 ++-
 src/backend/optimizer/util/pathnode.c         |  5 +
 src/include/nodes/pathnodes.h                 | 10 ++
 src/include/nodes/plannodes.h                 | 11 +++
 src/include/optimizer/pathnode.h              |  2 +
 11 files changed, 175 insertions(+), 27 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index ca9a23ea61f..a377fb2571d 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -104,6 +104,7 @@ $$);
                Parallel Safe: true
                Plan Node ID: 2
                Append RTIs: 1
+               Child Append RTIs: none
                ->  Seq Scan on brassica vegetables_1
                      Disabled Nodes: 0
                      Parallel Safe: true
@@ -142,7 +143,7 @@ $$);
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 3 4
-(53 rows)
+(54 rows)
 
 -- Test a different output format.
 SELECT explain_filter($$
@@ -197,6 +198,7 @@ $$);
                <extParam>none</extParam>                            +
                <allParam>none</allParam>                            +
                <Append-RTIs>1</Append-RTIs>                         +
+               <Child-Append-RTIs>none</Child-Append-RTIs>          +
                <Subplans-Removed>0</Subplans-Removed>               +
                <Plans>                                              +
                  <Plan>                                             +
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index e54f8cfc332..c28348ea966 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -54,6 +54,8 @@ static void overexplain_alias(const char *qlabel, Alias *alias,
 							  ExplainState *es);
 static void overexplain_bitmapset(const char *qlabel, Bitmapset *bms,
 								  ExplainState *es);
+static void overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+									   ExplainState *es);
 static void overexplain_intlist(const char *qlabel, List *list,
 								ExplainState *es);
 
@@ -232,11 +234,17 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 				overexplain_bitmapset("Append RTIs",
 									  ((Append *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((Append *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_MergeAppend:
 				overexplain_bitmapset("Append RTIs",
 									  ((MergeAppend *) plan)->apprelids,
 									  es);
+				overexplain_bitmapset_list("Child Append RTIs",
+										   ((MergeAppend *) plan)->child_append_relid_sets,
+										   es);
 				break;
 			case T_Result:
 
@@ -815,6 +823,54 @@ overexplain_bitmapset(const char *qlabel, Bitmapset *bms, ExplainState *es)
 	pfree(buf.data);
 }
 
+/*
+ * Emit a text property describing the contents of a list of bitmapsets.
+ * If a bitmapset contains exactly 1 member, we just print an integer;
+ * otherwise, we surround the list of members by parentheses.
+ *
+ * If there are no bitmapsets in the list, we print the word "none".
+ */
+static void
+overexplain_bitmapset_list(const char *qlabel, List *bms_list,
+						   ExplainState *es)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+
+	foreach_node(Bitmapset, bms, bms_list)
+	{
+		if (bms_membership(bms) == BMS_SINGLETON)
+			appendStringInfo(&buf, " %d", bms_singleton_member(bms));
+		else
+		{
+			int			x = -1;
+			bool		first = true;
+
+			appendStringInfoString(&buf, " (");
+			while ((x = bms_next_member(bms, x)) >= 0)
+			{
+				if (first)
+					first = false;
+				else
+					appendStringInfoChar(&buf, ' ');
+				appendStringInfo(&buf, "%d", x);
+			}
+			appendStringInfoChar(&buf, ')');
+		}
+	}
+
+	if (buf.len == 0)
+	{
+		ExplainPropertyText(qlabel, "none", es);
+		return;
+	}
+
+	Assert(buf.data[0] == ' ');
+	ExplainPropertyText(qlabel, buf.data + 1, es);
+	pfree(buf.data);
+}
+
 /*
  * Emit a text property describing the contents of a list of integers, OIDs,
  * or XIDs -- either a space-separated list of integer members, or the word
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 4c43fd0b19b..928b8d84ad8 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -128,8 +128,10 @@ static Path *get_cheapest_parameterized_child_path(PlannerInfo *root,
 												   Relids required_outer);
 static void accumulate_append_subpath(Path *path,
 									  List **subpaths,
-									  List **special_subpaths);
-static Path *get_singleton_append_subpath(Path *path);
+									  List **special_subpaths,
+									  List **child_append_relid_sets);
+static Path *get_singleton_append_subpath(Path *path,
+										  List **child_append_relid_sets);
 static void set_dummy_rel_pathlist(RelOptInfo *rel);
 static void set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 								  Index rti, RangeTblEntry *rte);
@@ -1406,11 +1408,15 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 {
 	List	   *subpaths = NIL;
 	bool		subpaths_valid = true;
+	List	   *subpath_cars = NIL;
 	List	   *startup_subpaths = NIL;
 	bool		startup_subpaths_valid = true;
+	List	   *startup_subpath_cars = NIL;
 	List	   *partial_subpaths = NIL;
+	List	   *partial_subpath_cars = NIL;
 	List	   *pa_partial_subpaths = NIL;
 	List	   *pa_nonpartial_subpaths = NIL;
+	List	   *pa_subpath_cars = NIL;
 	bool		partial_subpaths_valid = true;
 	bool		pa_subpaths_valid;
 	List	   *all_child_pathkeys = NIL;
@@ -1443,7 +1449,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		if (childrel->pathlist != NIL &&
 			childrel->cheapest_total_path->param_info == NULL)
 			accumulate_append_subpath(childrel->cheapest_total_path,
-									  &subpaths, NULL);
+									  &subpaths, NULL, &subpath_cars);
 		else
 			subpaths_valid = false;
 
@@ -1472,7 +1478,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 			Assert(cheapest_path->param_info == NULL);
 			accumulate_append_subpath(cheapest_path,
 									  &startup_subpaths,
-									  NULL);
+									  NULL,
+									  &startup_subpath_cars);
 		}
 		else
 			startup_subpaths_valid = false;
@@ -1483,7 +1490,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 		{
 			cheapest_partial_path = linitial(childrel->partial_pathlist);
 			accumulate_append_subpath(cheapest_partial_path,
-									  &partial_subpaths, NULL);
+									  &partial_subpaths, NULL,
+									  &partial_subpath_cars);
 		}
 		else
 			partial_subpaths_valid = false;
@@ -1512,7 +1520,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				Assert(cheapest_partial_path != NULL);
 				accumulate_append_subpath(cheapest_partial_path,
 										  &pa_partial_subpaths,
-										  &pa_nonpartial_subpaths);
+										  &pa_nonpartial_subpaths,
+										  &pa_subpath_cars);
 			}
 			else
 			{
@@ -1531,7 +1540,8 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				 */
 				accumulate_append_subpath(nppath,
 										  &pa_nonpartial_subpaths,
-										  NULL);
+										  NULL,
+										  &pa_subpath_cars);
 			}
 		}
 
@@ -1606,14 +1616,16 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 	 * if we have zero or one live subpath due to constraint exclusion.)
 	 */
 	if (subpaths_valid)
-		add_path(rel, (Path *) create_append_path(root, rel, subpaths, NIL,
+		add_path(rel, (Path *) create_append_path(root, rel, subpaths,
+												  NIL, subpath_cars,
 												  NIL, NULL, 0, false,
 												  -1));
 
 	/* build an AppendPath for the cheap startup paths, if valid */
 	if (startup_subpaths_valid)
 		add_path(rel, (Path *) create_append_path(root, rel, startup_subpaths,
-												  NIL, NIL, NULL, 0, false, -1));
+												  NIL, startup_subpath_cars,
+												  NIL, NULL, 0, false, -1));
 
 	/*
 	 * Consider an append of unordered, unparameterized partial paths.  Make
@@ -1654,6 +1666,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Generate a partial append path. */
 		appendpath = create_append_path(root, rel, NIL, partial_subpaths,
+										partial_subpath_cars,
 										NIL, NULL, parallel_workers,
 										enable_parallel_append,
 										-1);
@@ -1704,6 +1717,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		appendpath = create_append_path(root, rel, pa_nonpartial_subpaths,
 										pa_partial_subpaths,
+										pa_subpath_cars,
 										NIL, NULL, parallel_workers, true,
 										partial_rows);
 		add_partial_path(rel, (Path *) appendpath);
@@ -1737,6 +1751,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 
 		/* Select the child paths for an Append with this parameterization */
 		subpaths = NIL;
+		subpath_cars = NIL;
 		subpaths_valid = true;
 		foreach(lcr, live_childrels)
 		{
@@ -1759,12 +1774,13 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				subpaths_valid = false;
 				break;
 			}
-			accumulate_append_subpath(subpath, &subpaths, NULL);
+			accumulate_append_subpath(subpath, &subpaths, NULL,
+									  &subpath_cars);
 		}
 
 		if (subpaths_valid)
 			add_path(rel, (Path *)
-					 create_append_path(root, rel, subpaths, NIL,
+					 create_append_path(root, rel, subpaths, NIL, subpath_cars,
 										NIL, required_outer, 0, false,
 										-1));
 	}
@@ -1791,6 +1807,7 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel,
 				continue;
 
 			appendpath = create_append_path(root, rel, NIL, list_make1(path),
+											list_make1(rel->relids),
 											NIL, NULL,
 											path->parallel_workers, true,
 											partial_rows);
@@ -1874,8 +1891,11 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 	{
 		List	   *pathkeys = (List *) lfirst(lcp);
 		List	   *startup_subpaths = NIL;
+		List	   *startup_subpath_cars = NIL;
 		List	   *total_subpaths = NIL;
+		List	   *total_subpath_cars = NIL;
 		List	   *fractional_subpaths = NIL;
+		List	   *fractional_subpath_cars = NIL;
 		bool		startup_neq_total = false;
 		bool		fraction_neq_total = false;
 		bool		match_partition_order;
@@ -2038,16 +2058,23 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * just a single subpath (and hence aren't doing anything
 				 * useful).
 				 */
-				cheapest_startup = get_singleton_append_subpath(cheapest_startup);
-				cheapest_total = get_singleton_append_subpath(cheapest_total);
+				cheapest_startup =
+					get_singleton_append_subpath(cheapest_startup,
+												 &startup_subpath_cars);
+				cheapest_total =
+					get_singleton_append_subpath(cheapest_total,
+												 &total_subpath_cars);
 
 				startup_subpaths = lappend(startup_subpaths, cheapest_startup);
 				total_subpaths = lappend(total_subpaths, cheapest_total);
 
 				if (cheapest_fractional)
 				{
-					cheapest_fractional = get_singleton_append_subpath(cheapest_fractional);
-					fractional_subpaths = lappend(fractional_subpaths, cheapest_fractional);
+					cheapest_fractional =
+						get_singleton_append_subpath(cheapest_fractional,
+													 &fractional_subpath_cars);
+					fractional_subpaths =
+						lappend(fractional_subpaths, cheapest_fractional);
 				}
 			}
 			else
@@ -2057,13 +2084,16 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				 * child paths for the MergeAppend.
 				 */
 				accumulate_append_subpath(cheapest_startup,
-										  &startup_subpaths, NULL);
+										  &startup_subpaths, NULL,
+										  &startup_subpath_cars);
 				accumulate_append_subpath(cheapest_total,
-										  &total_subpaths, NULL);
+										  &total_subpaths, NULL,
+										  &total_subpath_cars);
 
 				if (cheapest_fractional)
 					accumulate_append_subpath(cheapest_fractional,
-											  &fractional_subpaths, NULL);
+											  &fractional_subpaths, NULL,
+											  &fractional_subpath_cars);
 			}
 		}
 
@@ -2075,6 +2105,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 													  rel,
 													  startup_subpaths,
 													  NIL,
+													  startup_subpath_cars,
 													  pathkeys,
 													  NULL,
 													  0,
@@ -2085,6 +2116,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  total_subpaths,
 														  NIL,
+														  total_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2096,6 +2128,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 														  rel,
 														  fractional_subpaths,
 														  NIL,
+														  fractional_subpath_cars,
 														  pathkeys,
 														  NULL,
 														  0,
@@ -2108,12 +2141,14 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 			add_path(rel, (Path *) create_merge_append_path(root,
 															rel,
 															startup_subpaths,
+															startup_subpath_cars,
 															pathkeys,
 															NULL));
 			if (startup_neq_total)
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																total_subpaths,
+																total_subpath_cars,
 																pathkeys,
 																NULL));
 
@@ -2121,6 +2156,7 @@ generate_orderedappend_paths(PlannerInfo *root, RelOptInfo *rel,
 				add_path(rel, (Path *) create_merge_append_path(root,
 																rel,
 																fractional_subpaths,
+																fractional_subpath_cars,
 																pathkeys,
 																NULL));
 		}
@@ -2223,7 +2259,8 @@ get_cheapest_parameterized_child_path(PlannerInfo *root, RelOptInfo *rel,
  * paths).
  */
 static void
-accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
+accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths,
+						  List **child_append_relid_sets)
 {
 	if (IsA(path, AppendPath))
 	{
@@ -2232,6 +2269,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		if (!apath->path.parallel_aware || apath->first_partial_path == 0)
 		{
 			*subpaths = list_concat(*subpaths, apath->subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 		else if (special_subpaths != NULL)
@@ -2246,6 +2285,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 												  apath->first_partial_path);
 			*special_subpaths = list_concat(*special_subpaths,
 											new_special_subpaths);
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return;
 		}
 	}
@@ -2254,6 +2295,8 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		*subpaths = list_concat(*subpaths, mpath->subpaths);
+		*child_append_relid_sets =
+			lappend(*child_append_relid_sets, path->parent->relids);
 		return;
 	}
 
@@ -2265,10 +2308,15 @@ accumulate_append_subpath(Path *path, List **subpaths, List **special_subpaths)
  *		Returns the single subpath of an Append/MergeAppend, or just
  *		return 'path' if it's not a single sub-path Append/MergeAppend.
  *
+ * As a side effect, whenever we return a single subpath rather than the
+ * original path, add the relid set for the original path to
+ * child_append_relid_sets, so that those relids don't entirely disappear
+ * from the final plan.
+ *
  * Note: 'path' must not be a parallel-aware path.
  */
 static Path *
-get_singleton_append_subpath(Path *path)
+get_singleton_append_subpath(Path *path, List **child_append_relid_sets)
 {
 	Assert(!path->parallel_aware);
 
@@ -2277,14 +2325,22 @@ get_singleton_append_subpath(Path *path)
 		AppendPath *apath = (AppendPath *) path;
 
 		if (list_length(apath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(apath->subpaths);
+		}
 	}
 	else if (IsA(path, MergeAppendPath))
 	{
 		MergeAppendPath *mpath = (MergeAppendPath *) path;
 
 		if (list_length(mpath->subpaths) == 1)
+		{
+			*child_append_relid_sets =
+				lappend(*child_append_relid_sets, path->parent->relids);
 			return (Path *) linitial(mpath->subpaths);
+		}
 	}
 
 	return path;
@@ -2313,7 +2369,7 @@ set_dummy_rel_pathlist(RelOptInfo *rel)
 	rel->partial_pathlist = NIL;
 
 	/* Set up the dummy path */
-	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
+	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL, NIL,
 											  NIL, rel->lateral_relids,
 											  0, false, -1));
 
diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 8827b9a5245..76478389653 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -1530,7 +1530,7 @@ mark_dummy_rel(RelOptInfo *rel)
 
 	/* Set up the dummy path */
 	add_path(rel, (Path *) create_append_path(NULL, rel, NIL, NIL,
-											  NIL, rel->lateral_relids,
+											  NIL, NIL, rel->lateral_relids,
 											  0, false, -1));
 
 	/* Set or update cheapest_total_path and related fields */
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index bc417f93840..e3f27a586ca 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -1265,6 +1265,7 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags)
 	plan->plan.lefttree = NULL;
 	plan->plan.righttree = NULL;
 	plan->apprelids = rel->relids;
+	plan->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	if (pathkeys != NIL)
 	{
@@ -1477,6 +1478,7 @@ create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path,
 	plan->lefttree = NULL;
 	plan->righttree = NULL;
 	node->apprelids = rel->relids;
+	node->child_append_relid_sets = best_path->child_append_relid_sets;
 
 	/*
 	 * Compute sort column info, and adjust MergeAppend's tlist as needed.
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index df9d03d5492..94e1ac96ed9 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4027,6 +4027,7 @@ create_degenerate_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
 							   paths,
 							   NIL,
 							   NIL,
+							   NIL,
 							   NULL,
 							   0,
 							   false,
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index a01b02f3a7b..12d0c821ed7 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -843,7 +843,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 	 * union child.
 	 */
 	apath = (Path *) create_append_path(root, result_rel, cheapest_pathlist,
-										NIL, NIL, NULL, 0, false, -1);
+										NIL, NIL, NIL, NULL, 0, false, -1);
 
 	/*
 	 * Estimate number of groups.  For now we just assume the output is unique
@@ -889,7 +889,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 
 		papath = (Path *)
 			create_append_path(root, result_rel, NIL, partial_pathlist,
-							   NIL, NULL, parallel_workers,
+							   NIL, NIL, NULL, parallel_workers,
 							   enable_parallel_append, -1);
 		gpath = (Path *)
 			create_gather_path(root, result_rel, papath,
@@ -1018,6 +1018,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root,
 			path = (Path *) create_merge_append_path(root,
 													 result_rel,
 													 ordered_pathlist,
+													 NIL,
 													 union_pathkeys,
 													 NULL);
 
@@ -1224,8 +1225,10 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root,
 				 * between the set op targetlist and the targetlist of the
 				 * left input.  The Append will be removed in setrefs.c.
 				 */
-				apath = (Path *) create_append_path(root, result_rel, list_make1(lpath),
-													NIL, NIL, NULL, 0, false, -1);
+				apath = (Path *) create_append_path(root, result_rel,
+													list_make1(lpath),
+													NIL, NIL, NIL, NULL, 0,
+													false, -1);
 
 				add_path(result_rel, apath);
 
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index b6be4ddbd01..33ce34f0088 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1301,6 +1301,7 @@ AppendPath *
 create_append_path(PlannerInfo *root,
 				   RelOptInfo *rel,
 				   List *subpaths, List *partial_subpaths,
+				   List *child_append_relid_sets,
 				   List *pathkeys, Relids required_outer,
 				   int parallel_workers, bool parallel_aware,
 				   double rows)
@@ -1310,6 +1311,7 @@ create_append_path(PlannerInfo *root,
 
 	Assert(!parallel_aware || parallel_workers > 0);
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_Append;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -1472,6 +1474,7 @@ MergeAppendPath *
 create_merge_append_path(PlannerInfo *root,
 						 RelOptInfo *rel,
 						 List *subpaths,
+						 List *child_append_relid_sets,
 						 List *pathkeys,
 						 Relids required_outer)
 {
@@ -1487,6 +1490,7 @@ create_merge_append_path(PlannerInfo *root,
 	 */
 	Assert(bms_is_empty(rel->lateral_relids) && bms_is_empty(required_outer));
 
+	pathnode->child_append_relid_sets = child_append_relid_sets;
 	pathnode->path.pathtype = T_MergeAppend;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = rel->reltarget;
@@ -3951,6 +3955,7 @@ reparameterize_path(PlannerInfo *root, Path *path,
 				}
 				return (Path *)
 					create_append_path(root, rel, childpaths, partialpaths,
+									   apath->child_append_relid_sets,
 									   apath->path.pathkeys, required_outer,
 									   apath->path.parallel_workers,
 									   apath->path.parallel_aware,
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 71692097355..eacae16b827 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2172,6 +2172,12 @@ typedef struct CustomPath
  * For partial Append, 'subpaths' contains non-partial subpaths followed by
  * partial subpaths.
  *
+ * Whenever accumulate_append_subpath() allows us to consolidate multiple
+ * levels of Append paths down to one, we store the RTI sets for the omitted
+ * paths in child_append_relid_sets. This is not necessary for planning or
+ * execution; we do it for the benefit of code that wants to inspect the
+ * final plan and understand how it came to be.
+ *
  * Note: it is possible for "subpaths" to contain only one, or even no,
  * elements.  These cases are optimized during create_append_plan.
  * In particular, an AppendPath with no subpaths is a "dummy" path that
@@ -2187,6 +2193,7 @@ typedef struct AppendPath
 	/* Index of first partial path in subpaths; list_length(subpaths) if none */
 	int			first_partial_path;
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } AppendPath;
 
 #define IS_DUMMY_APPEND(p) \
@@ -2203,12 +2210,15 @@ extern bool is_dummy_rel(RelOptInfo *rel);
 /*
  * MergeAppendPath represents a MergeAppend plan, ie, the merging of sorted
  * results from several member plans to produce similarly-sorted output.
+ *
+ * child_append_relid_sets has the same meaning here as for AppendPath.
  */
 typedef struct MergeAppendPath
 {
 	Path		path;
 	List	   *subpaths;		/* list of component Paths */
 	Cardinality limit_tuples;	/* hard limit on output tuples, or -1 */
+	List	   *child_append_relid_sets;
 } MergeAppendPath;
 
 /*
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index edfd02230a6..7248db216bb 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -394,9 +394,16 @@ struct PartitionPruneInfo;		/* forward reference to struct below */
 typedef struct Append
 {
 	Plan		plan;
+
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
+
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *appendplans;
+
 	/* # of asynchronous plans */
 	int			nasyncplans;
 
@@ -426,6 +433,10 @@ typedef struct MergeAppend
 	/* RTIs of appendrel(s) formed by this node */
 	Bitmapset  *apprelids;
 
+	/* sets of RTIs of appendrels consolidated into this node */
+	List	   *child_append_relid_sets;
+
+	/* plans to run */
 	List	   *mergeplans;
 
 	/* these fields are just like the sort-key info in struct Sort: */
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index 6b010f0b1a5..dbf4702acc9 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -71,12 +71,14 @@ extern TidRangePath *create_tidrangescan_path(PlannerInfo *root,
 											  int parallel_workers);
 extern AppendPath *create_append_path(PlannerInfo *root, RelOptInfo *rel,
 									  List *subpaths, List *partial_subpaths,
+									  List *child_append_relid_sets,
 									  List *pathkeys, Relids required_outer,
 									  int parallel_workers, bool parallel_aware,
 									  double rows);
 extern MergeAppendPath *create_merge_append_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 List *subpaths,
+												 List *child_append_relid_sets,
 												 List *pathkeys,
 												 Relids required_outer);
 extern GroupResultPath *create_group_result_path(PlannerInfo *root,
-- 
2.51.0



  [application/octet-stream] v7-0002-Store-information-about-elided-nodes-in-the-final.patch (9.3K, 4-v7-0002-Store-information-about-elided-nodes-in-the-final.patch)
  download | inline diff:
From 98084b998e1b4035456b6959174b5e548b666fb6 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 20 Oct 2025 14:23:42 -0400
Subject: [PATCH v7 2/5] Store information about elided nodes in the final
 plan.

An extension (or core code) might want to reconstruct the planner's
choice of join order from the final plan. To do so, it must be possible
to find all of the RTIs that were part of the join problem in that plan.
The previous commit, together with the earlier work in
8c49a484e8ebb0199fba4bd68eaaedaf49b48ed0, is enough to let us match up
RTIs we see in the final plan with RTIs that we see during the planning
cycle, but we still have a problem if the planner decides to drop some
RTIs out of the final plan altogether.

To fix that, when setrefs.c removes a SubqueryScan, single-child Append,
or single-child MergeAppend from the final Plan tree, record the type of
the removed node and the RTIs that the removed node would have scanned
in the final plan tree. It would be natural to record this information
on the child of the removed plan node, but that would require adding
an additional pointer field to type Plan, which seems undesirable.
So, instead, store the information in a separate list that the
executor need never consult, and use the plan_node_id to identify
the plan node with which the removed node is logically associated.

Also, update pg_overexplain to display these details.
---
 .../expected/pg_overexplain.out               |  4 +-
 contrib/pg_overexplain/pg_overexplain.c       | 39 ++++++++++++++
 src/backend/optimizer/plan/planner.c          |  1 +
 src/backend/optimizer/plan/setrefs.c          | 52 ++++++++++++++++++-
 src/include/nodes/pathnodes.h                 |  3 ++
 src/include/nodes/plannodes.h                 | 17 ++++++
 src/tools/pgindent/typedefs.list              |  1 +
 7 files changed, 114 insertions(+), 3 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 55d34666d87..ca9a23ea61f 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -452,6 +452,8 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
  Seq Scan on daucus vegetables
    Filter: (genus = 'daucus'::text)
    Scan RTI: 2
+   Elided Node Type: Append
+   Elided Node RTIs: 1
  RTI 1 (relation, inherited, in-from-clause):
    Eref: vegetables (id, name, genus)
    Relation: vegetables
@@ -465,7 +467,7 @@ SELECT * FROM vegetables WHERE genus = 'daucus';
    Relation Kind: relation
    Relation Lock Mode: AccessShareLock
  Unprunable RTIs: 1 2
-(16 rows)
+(18 rows)
 
 -- Also test a case that involves a write.
 EXPLAIN (RANGE_TABLE, COSTS OFF)
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index 1c4c796adb2..e54f8cfc332 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -191,6 +191,8 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 	 */
 	if (options->range_table)
 	{
+		bool		opened_elided_nodes = false;
+
 		switch (nodeTag(plan))
 		{
 			case T_SeqScan:
@@ -251,6 +253,43 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 			default:
 				break;
 		}
+
+		foreach_node(ElidedNode, n, es->pstmt->elidedNodes)
+		{
+			char	   *elidednodetag;
+
+			if (n->plan_node_id != plan->plan_node_id)
+				continue;
+
+			if (!opened_elided_nodes)
+			{
+				ExplainOpenGroup("Elided Nodes", "Elided Nodes", false, es);
+				opened_elided_nodes = true;
+			}
+
+			switch (n->elided_type)
+			{
+				case T_Append:
+					elidednodetag = "Append";
+					break;
+				case T_MergeAppend:
+					elidednodetag = "MergeAppend";
+					break;
+				case T_SubqueryScan:
+					elidednodetag = "SubqueryScan";
+					break;
+				default:
+					elidednodetag = psprintf("%d", n->elided_type);
+					break;
+			}
+
+			ExplainOpenGroup("Elided Node", NULL, true, es);
+			ExplainPropertyText("Elided Node Type", elidednodetag, es);
+			overexplain_bitmapset("Elided Node RTIs", n->relids, es);
+			ExplainCloseGroup("Elided Node", NULL, true, es);
+		}
+		if (opened_elided_nodes)
+			ExplainCloseGroup("Elided Nodes", "Elided Nodes", false, es);
 	}
 }
 
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 31dcbdf3422..df9d03d5492 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -618,6 +618,7 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 	result->paramExecTypes = glob->paramExecTypes;
 	/* utilityStmt should be null, but we might as well copy it */
 	result->utilityStmt = parse->utilityStmt;
+	result->elidedNodes = glob->elidedNodes;
 	result->stmt_location = parse->stmt_location;
 	result->stmt_len = parse->stmt_len;
 
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index edcd4aaa53e..407ec39b63b 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -211,6 +211,9 @@ static List *set_windowagg_runcondition_references(PlannerInfo *root,
 												   List *runcondition,
 												   Plan *plan);
 
+static void record_elided_node(PlannerGlobal *glob, int plan_node_id,
+							   NodeTag elided_type, Bitmapset *relids);
+
 
 /*****************************************************************************
  *
@@ -1460,10 +1463,17 @@ set_subqueryscan_references(PlannerInfo *root,
 
 	if (trivial_subqueryscan(plan))
 	{
+		Index		scanrelid;
+
 		/*
 		 * We can omit the SubqueryScan node and just pull up the subplan.
 		 */
 		result = clean_up_removed_plan_level((Plan *) plan, plan->subplan);
+
+		/* Remember that we removed a SubqueryScan */
+		scanrelid = plan->scan.scanrelid + rtoffset;
+		record_elided_node(root->glob, plan->subplan->plan_node_id,
+						   T_SubqueryScan, bms_make_singleton(scanrelid));
 	}
 	else
 	{
@@ -1891,7 +1901,17 @@ set_append_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(aplan->appendplans);
 
 		if (p->parallel_aware == aplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) aplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) aplan, p);
+
+			/* Remember that we removed an Append */
+			record_elided_node(root->glob, p->plan_node_id, T_Append,
+							   offset_relid_set(aplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -1959,7 +1979,17 @@ set_mergeappend_references(PlannerInfo *root,
 		Plan	   *p = (Plan *) linitial(mplan->mergeplans);
 
 		if (p->parallel_aware == mplan->plan.parallel_aware)
-			return clean_up_removed_plan_level((Plan *) mplan, p);
+		{
+			Plan	   *result;
+
+			result = clean_up_removed_plan_level((Plan *) mplan, p);
+
+			/* Remember that we removed a MergeAppend */
+			record_elided_node(root->glob, p->plan_node_id, T_MergeAppend,
+							   offset_relid_set(mplan->apprelids, rtoffset));
+
+			return result;
+		}
 	}
 
 	/*
@@ -3774,3 +3804,21 @@ extract_query_dependencies_walker(Node *node, PlannerInfo *context)
 	return expression_tree_walker(node, extract_query_dependencies_walker,
 								  context);
 }
+
+/*
+ * Record some details about a node removed from the plan during setrefs
+ * processing, for the benefit of code trying to reconstruct planner decisions
+ * from examination of the final plan tree.
+ */
+static void
+record_elided_node(PlannerGlobal *glob, int plan_node_id,
+				   NodeTag elided_type, Bitmapset *relids)
+{
+	ElidedNode *n = makeNode(ElidedNode);
+
+	n->plan_node_id = plan_node_id;
+	n->elided_type = elided_type;
+	n->relids = relids;
+
+	glob->elidedNodes = lappend(glob->elidedNodes, n);
+}
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 4b892414d58..71692097355 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -159,6 +159,9 @@ typedef struct PlannerGlobal
 	/* type OIDs for PARAM_EXEC Params */
 	List	   *paramExecTypes;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/* highest PlaceHolderVar ID assigned */
 	Index		lastPHId;
 
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 48331893a63..edfd02230a6 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -152,6 +152,9 @@ typedef struct PlannedStmt
 	/* non-null if this is utility stmt */
 	Node	   *utilityStmt;
 
+	/* info about nodes elided from the plan during setrefs processing */
+	List	   *elidedNodes;
+
 	/*
 	 * DefElem objects added by extensions, e.g. using planner_shutdown_hook
 	 *
@@ -1838,4 +1841,18 @@ typedef struct SubPlanRTInfo
 	bool		dummy;
 } SubPlanRTInfo;
 
+/*
+ * ElidedNode
+ *
+ * Information about nodes elided from the final plan tree: trivial subquery
+ * scans, and single-child Append and MergeAppend nodes.
+ */
+typedef struct ElidedNode
+{
+	NodeTag		type;
+	int			plan_node_id;
+	NodeTag		elided_type;
+	Bitmapset  *relids;
+} ElidedNode;
+
 #endif							/* PLANNODES_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 14c01f80b22..58c9a3f1e01 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -707,6 +707,7 @@ EachState
 Edge
 EditableObjectType
 ElementsState
+ElidedNode
 EnableTimeoutParams
 EndDataPtrType
 EndDirectModify_function
-- 
2.51.0



  [application/octet-stream] v7-0004-Allow-for-plugin-control-over-path-generation-str.patch (55.7K, 5-v7-0004-Allow-for-plugin-control-over-path-generation-str.patch)
  download | inline diff:
From 2cd3edc0e5339434601e4c768dc853790d344b0f Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 24 Oct 2025 15:11:47 -0400
Subject: [PATCH v7 4/5] Allow for plugin control over path generation
 strategies.

Each RelOptInfo now has a pgs_mask member which is a mask of acceptable
strategies. For most rels, this is populated from PlannerGlobal's
default_pgs_mask, which is computed from the values of the enable_*
GUCs at the start of planning.

For baserels, get_relation_info_hook can be used to adjust pgs_mask for
each new RelOptInfo, at least for rels of type RTE_RELATION. Adjusting
pgs_mask is less useful for other types of rels, but if it proves to
be necessary, we can revisit the way this hook works or add a new one.

For joinrels, two new hooks are added. joinrel_setup_hook is called each
time a joinrel is created, and one thing that can be done from that hook
is to manipulate pgs_mask for the new joinrel. join_path_setup_hook is
called each time we're about to add paths to a joinrel by considering
some particular combination of an outer rel, an inner rel, and a join
type. It can modify the pgs_mask propagated into JoinPathExtraData to
restrict strategy choice for that paricular combination of rels.

To make joinrel_setup_hook work as intended, the existing calls to
build_joinrel_partition_info are moved later in the calling functions;
this is because that function checks whether the rel's pgs_mask includes
PGS_CONSIDER_PARTITIONWISE, so we want it to only be called after
plugins have had a chance to alter pgs_mask.

Upper rels currently inherit pgs_mask from the input relation. It's
unclear that this is the most useful behavior, but at the moment there
are no hooks to allow the mask to be set in any other way.
---
 src/backend/optimizer/path/allpaths.c   |   2 +-
 src/backend/optimizer/path/costsize.c   | 222 ++++++++++++++++++------
 src/backend/optimizer/path/indxpath.c   |   4 +-
 src/backend/optimizer/path/joinpath.c   |  89 +++++++---
 src/backend/optimizer/path/tidpath.c    |   7 +-
 src/backend/optimizer/plan/createplan.c |   4 +-
 src/backend/optimizer/plan/planner.c    |  54 ++++++
 src/backend/optimizer/util/pathnode.c   |  19 +-
 src/backend/optimizer/util/plancat.c    |   3 +
 src/backend/optimizer/util/relnode.c    |  43 ++++-
 src/include/nodes/pathnodes.h           |  82 ++++++++-
 src/include/optimizer/cost.h            |   4 +-
 src/include/optimizer/pathnode.h        |  11 +-
 src/include/optimizer/paths.h           |   9 +-
 14 files changed, 455 insertions(+), 98 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 928b8d84ad8..8e9dde3d195 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -954,7 +954,7 @@ set_tablesample_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *
 		 bms_membership(root->all_query_rels) != BMS_SINGLETON) &&
 		!(GetTsmRoutine(rte->tablesample->tsmhandler)->repeatable_across_scans))
 	{
-		path = (Path *) create_material_path(rel, path);
+		path = (Path *) create_material_path(rel, path, true);
 	}
 
 	add_path(rel, path);
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index a39cc793b4d..51940aec820 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -275,6 +275,7 @@ cost_seqscan(Path *path, PlannerInfo *root,
 	double		spc_seq_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = PGS_SEQSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -327,8 +328,11 @@ cost_seqscan(Path *path, PlannerInfo *root,
 		 */
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	path->disabled_nodes = enable_seqscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
 }
@@ -354,6 +358,7 @@ cost_samplescan(Path *path, PlannerInfo *root,
 				spc_page_cost;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations with tablesample clauses */
 	Assert(baserel->relid > 0);
@@ -401,7 +406,11 @@ cost_samplescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -440,7 +449,8 @@ cost_gather(GatherPath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows;
 
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost;
 	path->path.total_cost = (startup_cost + run_cost);
 }
@@ -506,8 +516,8 @@ cost_gather_merge(GatherMergePath *path, PlannerInfo *root,
 	startup_cost += parallel_setup_cost;
 	run_cost += parallel_tuple_cost * path->path.rows * 1.05;
 
-	path->path.disabled_nodes = input_disabled_nodes
-		+ (enable_gathermerge ? 0 : 1);
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ ((rel->pgs_mask & PGS_GATHER_MERGE) != 0 ? 0 : 1);
 	path->path.startup_cost = startup_cost + input_startup_cost;
 	path->path.total_cost = (startup_cost + run_cost + input_total_cost);
 }
@@ -557,6 +567,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	double		pages_fetched;
 	double		rand_heap_pages;
 	double		index_pages;
+	uint64		enable_mask;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo) &&
@@ -588,8 +599,11 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 											  path->indexclauses);
 	}
 
-	/* we don't need to check enable_indexonlyscan; indxpath.c does that */
-	path->path.disabled_nodes = enable_indexscan ? 0 : 1;
+	/* is this scan type disabled? */
+	enable_mask = (indexonly ? PGS_INDEXONLYSCAN : PGS_INDEXSCAN)
+		| (path->path.parallel_workers == 0 ? PGS_CONSIDER_NONPARTIAL : 0);
+	path->path.disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 
 	/*
 	 * Call index-access-method-specific code to estimate the processing cost
@@ -1010,6 +1024,7 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	double		spc_seq_page_cost,
 				spc_random_page_cost;
 	double		T;
+	uint64		enable_mask = PGS_BITMAPSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(IsA(baserel, RelOptInfo));
@@ -1075,6 +1090,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
+	else
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 
 	run_cost += cpu_run_cost;
@@ -1083,7 +1100,8 @@ cost_bitmap_heap_scan(Path *path, PlannerInfo *root, RelOptInfo *baserel,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = enable_bitmapscan ? 0 : 1;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1240,6 +1258,7 @@ cost_tidscan(Path *path, PlannerInfo *root,
 	double		ntuples;
 	ListCell   *l;
 	double		spc_random_page_cost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1261,10 +1280,10 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 		/*
 		 * We must use a TID scan for CurrentOfExpr; in any other case, we
-		 * should be generating a TID scan only if enable_tidscan=true. Also,
-		 * if CurrentOfExpr is the qual, there should be only one.
+		 * should be generating a TID scan only if TID scans are allowed.
+		 * Also, if CurrentOfExpr is the qual, there should be only one.
 		 */
-		Assert(enable_tidscan || IsA(qual, CurrentOfExpr));
+		Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0 || IsA(qual, CurrentOfExpr));
 		Assert(list_length(tidquals) == 1 || !IsA(qual, CurrentOfExpr));
 
 		if (IsA(qual, ScalarArrayOpExpr))
@@ -1316,10 +1335,14 @@ cost_tidscan(Path *path, PlannerInfo *root,
 
 	/*
 	 * There are assertions above verifying that we only reach this function
-	 * either when enable_tidscan=true or when the TID scan is the only legal
-	 * path, so it's safe to set disabled_nodes to zero here.
+	 * either when baserel->pgs_mask includes PGS_TIDSCAN or when the TID scan
+	 * is the only legal path, so we only need to consider the effects of
+	 * PGS_CONSIDER_NONPARTIAL here.
 	 */
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1350,6 +1373,7 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 	double		nseqpages;
 	double		spc_random_page_cost;
 	double		spc_seq_page_cost;
+	uint64		enable_mask = PGS_TIDSCAN;
 
 	/* Should only be applied to base relations */
 	Assert(baserel->relid > 0);
@@ -1428,8 +1452,15 @@ cost_tidrangescan(Path *path, PlannerInfo *root,
 		path->rows = clamp_row_est(path->rows / parallel_divisor);
 	}
 
-	/* we should not generate this path type when enable_tidscan=false */
-	Assert(enable_tidscan);
+	/*
+	 * We should not generate this path type when PGS_TIDSCAN is unset, but we
+	 * might need to disable this path due to PGS_CONSIDER_NONPARTIAL.
+	 */
+	Assert((baserel->pgs_mask & PGS_TIDSCAN) != 0);
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->disabled_nodes = 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + cpu_run_cost + disk_run_cost;
@@ -1453,6 +1484,7 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	List	   *qpquals;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are subqueries */
 	Assert(baserel->relid > 0);
@@ -1483,7 +1515,10 @@ cost_subqueryscan(SubqueryScanPath *path, PlannerInfo *root,
 	 * SubqueryScan node, plus cpu_tuple_cost to account for selection and
 	 * projection overhead.
 	 */
-	path->path.disabled_nodes = path->subpath->disabled_nodes;
+	if (path->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->path.disabled_nodes = path->subpath->disabled_nodes
+		+ (((baserel->pgs_mask & enable_mask) != enable_mask) ? 1 : 0);
 	path->path.startup_cost = path->subpath->startup_cost;
 	path->path.total_cost = path->subpath->total_cost;
 
@@ -1534,6 +1569,7 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1574,7 +1610,10 @@ cost_functionscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1596,6 +1635,7 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	Cost		cpu_per_tuple;
 	RangeTblEntry *rte;
 	QualCost	exprcost;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are functions */
 	Assert(baserel->relid > 0);
@@ -1631,7 +1671,10 @@ cost_tablefuncscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1651,6 +1694,7 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are values lists */
 	Assert(baserel->relid > 0);
@@ -1679,7 +1723,10 @@ cost_valuesscan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1702,6 +1749,7 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are CTEs */
 	Assert(baserel->relid > 0);
@@ -1727,7 +1775,10 @@ cost_ctescan(Path *path, PlannerInfo *root,
 	startup_cost += path->pathtarget->cost.startup;
 	run_cost += path->pathtarget->cost.per_tuple * path->rows;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1744,6 +1795,7 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to base relations that are Tuplestores */
 	Assert(baserel->relid > 0);
@@ -1765,7 +1817,10 @@ cost_namedtuplestorescan(Path *path, PlannerInfo *root,
 	cpu_per_tuple += cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1782,6 +1837,7 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	Cost		run_cost = 0;
 	QualCost	qpqual_cost;
 	Cost		cpu_per_tuple;
+	uint64		enable_mask = 0;
 
 	/* Should only be applied to RTE_RESULT base relations */
 	Assert(baserel->relid > 0);
@@ -1800,7 +1856,10 @@ cost_resultscan(Path *path, PlannerInfo *root,
 	cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple;
 	run_cost += cpu_per_tuple * baserel->tuples;
 
-	path->disabled_nodes = 0;
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	path->disabled_nodes =
+		(baserel->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -1818,6 +1877,7 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	Cost		startup_cost;
 	Cost		total_cost;
 	double		total_rows;
+	uint64		enable_mask = 0;
 
 	/* We probably have decent estimates for the non-recursive term */
 	startup_cost = nrterm->startup_cost;
@@ -1840,7 +1900,10 @@ cost_recursive_union(Path *runion, Path *nrterm, Path *rterm)
 	 */
 	total_cost += cpu_tuple_cost * total_rows;
 
-	runion->disabled_nodes = nrterm->disabled_nodes + rterm->disabled_nodes;
+	if (runion->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	runion->disabled_nodes =
+		(runion->parent->pgs_mask & enable_mask) != enable_mask ? 1 : 0;
 	runion->startup_cost = startup_cost;
 	runion->total_cost = total_cost;
 	runion->rows = total_rows;
@@ -2110,7 +2173,11 @@ cost_incremental_sort(Path *path,
 
 	path->rows = input_tuples;
 
-	/* should not generate these paths when enable_incremental_sort=false */
+	/*
+	 * We should not generate these paths when enable_incremental_sort=false.
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	Assert(enable_incremental_sort);
 	path->disabled_nodes = input_disabled_nodes;
 
@@ -2148,6 +2215,10 @@ cost_sort(Path *path, PlannerInfo *root,
 
 	startup_cost += input_cost;
 
+	/*
+	 * We can ignore PGS_CONSIDER_NONPARTIAL here, because if it's relevant,
+	 * it will have already affected the input path.
+	 */
 	path->rows = tuples;
 	path->disabled_nodes = input_disabled_nodes + (enable_sort ? 0 : 1);
 	path->startup_cost = startup_cost;
@@ -2239,9 +2310,15 @@ append_nonpartial_cost(List *subpaths, int numpaths, int parallel_workers)
 void
 cost_append(AppendPath *apath, PlannerInfo *root)
 {
+	RelOptInfo *rel = apath->path.parent;
 	ListCell   *l;
+	uint64		enable_mask = PGS_APPEND;
+
+	if (apath->path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
-	apath->path.disabled_nodes = 0;
+	apath->path.disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	apath->path.startup_cost = 0;
 	apath->path.total_cost = 0;
 	apath->path.rows = 0;
@@ -2451,11 +2528,16 @@ cost_merge_append(Path *path, PlannerInfo *root,
 				  Cost input_startup_cost, Cost input_total_cost,
 				  double tuples)
 {
+	RelOptInfo *rel = path->parent;
 	Cost		startup_cost = 0;
 	Cost		run_cost = 0;
 	Cost		comparison_cost;
 	double		N;
 	double		logN;
+	uint64		enable_mask = PGS_MERGE_APPEND;
+
+	if (path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/*
 	 * Avoid log(0)...
@@ -2478,7 +2560,9 @@ cost_merge_append(Path *path, PlannerInfo *root,
 	 */
 	run_cost += cpu_tuple_cost * APPEND_CPU_COST_MULTIPLIER * tuples;
 
-	path->disabled_nodes = input_disabled_nodes;
+	path->disabled_nodes =
+		(rel->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
+	path->disabled_nodes += input_disabled_nodes;
 	path->startup_cost = startup_cost + input_startup_cost;
 	path->total_cost = startup_cost + run_cost + input_total_cost;
 }
@@ -2497,7 +2581,7 @@ cost_merge_append(Path *path, PlannerInfo *root,
  */
 void
 cost_material(Path *path,
-			  int input_disabled_nodes,
+			  bool enabled, int input_disabled_nodes,
 			  Cost input_startup_cost, Cost input_total_cost,
 			  double tuples, int width)
 {
@@ -2506,6 +2590,11 @@ cost_material(Path *path,
 	double		nbytes = relation_byte_size(tuples, width);
 	double		work_mem_bytes = work_mem * (Size) 1024;
 
+	if (path->parallel_workers == 0 &&
+		path->parent != NULL &&
+		(path->parent->pgs_mask & PGS_CONSIDER_NONPARTIAL) == 0)
+		enabled = false;
+
 	path->rows = tuples;
 
 	/*
@@ -2535,7 +2624,7 @@ cost_material(Path *path,
 		run_cost += seq_page_cost * npages;
 	}
 
-	path->disabled_nodes = input_disabled_nodes + (enable_material ? 0 : 1);
+	path->disabled_nodes = input_disabled_nodes + (enabled ? 0 : 1);
 	path->startup_cost = startup_cost;
 	path->total_cost = startup_cost + run_cost;
 }
@@ -3287,7 +3376,7 @@ cost_group(Path *path, PlannerInfo *root,
  */
 void
 initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
-					  JoinType jointype,
+					  JoinType jointype, uint64 enable_mask,
 					  Path *outer_path, Path *inner_path,
 					  JoinPathExtraData *extra)
 {
@@ -3301,7 +3390,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Cost		inner_rescan_run_cost;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_nestloop ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
@@ -3701,7 +3790,19 @@ initial_cost_mergejoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	Assert(outerstartsel <= outerendsel);
 	Assert(innerstartsel <= innerendsel);
 
-	disabled_nodes = enable_mergejoin ? 0 : 1;
+	/*
+	 * We don't decide whether to materialize the inner path until we get to
+	 * final_cost_mergejoin(), so we don't know whether to check the pgs_mask
+	 * again PGS_MERGEJOIN_PLAIN or PGS_MERGEJOIN_MATERIALIZE. Instead, we
+	 * just account for any child nodes here and assume that this node is not
+	 * itslef disabled; we can sort out the details in final_cost_mergejoin().
+	 *
+	 * (We could be more precise here by setting disabled_nodes to 1 at this
+	 * stage if both PGS_MERGEJOIN_PLAIN and PGS_MERGEJOIN_MATERIALIZE are
+	 * disabled, but that seems to against the idea of making this function
+	 * produce a quick, optimistic approximation of the final cost.)
+	 */
+	disabled_nodes = 0;
 
 	/* cost of source data */
 
@@ -3880,9 +3981,7 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	double		mergejointuples,
 				rescannedtuples;
 	double		rescanratio;
-
-	/* Set the number of disabled nodes. */
-	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+	uint64		enable_mask = 0;
 
 	/* Protect some assumptions below that rowcounts aren't zero */
 	if (inner_path_rows <= 0)
@@ -4012,16 +4111,20 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 		path->materialize_inner = false;
 
 	/*
-	 * Prefer materializing if it looks cheaper, unless the user has asked to
-	 * suppress materialization.
+	 * If merge joins with materialization are enabled, then choose
+	 * materialization if either (a) it looks cheaper or (b) merge joins
+	 * without materialization are disabled.
 	 */
-	else if (enable_material && mat_inner_cost < bare_inner_cost)
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 (mat_inner_cost < bare_inner_cost ||
+			  (extra->pgs_mask & PGS_MERGEJOIN_PLAIN) == 0))
 		path->materialize_inner = true;
 
 	/*
-	 * Even if materializing doesn't look cheaper, we *must* do it if the
-	 * inner path is to be used directly (without sorting) and it doesn't
-	 * support mark/restore.
+	 * Regardless of what plan shapes are enabled and what the costs seem to
+	 * be, we *must* materialize it if the inner path is to be used directly
+	 * (without sorting) and it doesn't support mark/restore. Planner failure
+	 * is not an option!
 	 *
 	 * Since the inner side must be ordered, and only Sorts and IndexScans can
 	 * create order to begin with, and they both support mark/restore, you
@@ -4029,10 +4132,6 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * merge joins can *preserve* the order of their inputs, so they can be
 	 * selected as the input of a mergejoin, and they don't support
 	 * mark/restore at present.
-	 *
-	 * We don't test the value of enable_material here, because
-	 * materialization is required for correctness in this case, and turning
-	 * it off does not entitle us to deliver an invalid plan.
 	 */
 	else if (innersortkeys == NIL &&
 			 !ExecSupportsMarkRestore(inner_path))
@@ -4046,10 +4145,11 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	 * though.
 	 *
 	 * Since materialization is a performance optimization in this case,
-	 * rather than necessary for correctness, we skip it if enable_material is
-	 * off.
+	 * rather than necessary for correctness, we skip it if materialization is
+	 * switched off.
 	 */
-	else if (enable_material && innersortkeys != NIL &&
+	else if ((extra->pgs_mask & PGS_MERGEJOIN_MATERIALIZE) != 0 &&
+			 innersortkeys != NIL &&
 			 relation_byte_size(inner_path_rows,
 								inner_path->pathtarget->width) >
 			 work_mem * (Size) 1024)
@@ -4057,11 +4157,29 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path,
 	else
 		path->materialize_inner = false;
 
-	/* Charge the right incremental cost for the chosen case */
+	/* Get the number of disabled nodes, not yet including this one. */
+	path->jpath.path.disabled_nodes = workspace->disabled_nodes;
+
+	/*
+	 * Charge the right incremental cost for the chosen case, and update
+	 * enable_mask as appropriate.
+	 */
 	if (path->materialize_inner)
+	{
 		run_cost += mat_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
 	else
+	{
 		run_cost += bare_inner_cost;
+		enable_mask |= PGS_MERGEJOIN_PLAIN;
+	}
+
+	/* Incremental count of disabled nodes if this node is disabled. */
+	if (path->jpath.path.parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
+	if ((extra->pgs_mask & enable_mask) != enable_mask)
+		++path->jpath.path.disabled_nodes;
 
 	/* CPU costs */
 
@@ -4199,9 +4317,13 @@ initial_cost_hashjoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	int			numbatches;
 	int			num_skew_mcvs;
 	size_t		space_allowed;	/* unused */
+	uint64		enable_mask = PGS_HASHJOIN;
+
+	if (outer_path->parallel_workers == 0)
+		enable_mask |= PGS_CONSIDER_NONPARTIAL;
 
 	/* Count up disabled nodes. */
-	disabled_nodes = enable_hashjoin ? 0 : 1;
+	disabled_nodes = (extra->pgs_mask & enable_mask) == enable_mask ? 0 : 1;
 	disabled_nodes += inner_path->disabled_nodes;
 	disabled_nodes += outer_path->disabled_nodes;
 
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index 5d4f81ee77e..8922b68033a 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -2232,8 +2232,8 @@ check_index_only(RelOptInfo *rel, IndexOptInfo *index)
 	ListCell   *lc;
 	int			i;
 
-	/* Index-only scans must be enabled */
-	if (!enable_indexonlyscan)
+	/* If we're not allowed to consider index-only scans, give up now */
+	if ((rel->pgs_mask & PGS_CONSIDER_INDEXONLY) == 0)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index ea5b6415186..82dab3d6004 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -29,8 +29,9 @@
 #include "utils/lsyscache.h"
 #include "utils/typcache.h"
 
-/* Hook for plugins to get control in add_paths_to_joinrel() */
+/* Hooks for plugins to get control in add_paths_to_joinrel() */
 set_join_pathlist_hook_type set_join_pathlist_hook = NULL;
+join_path_setup_hook_type join_path_setup_hook = NULL;
 
 /*
  * Paths parameterized by a parent rel can be considered to be parameterized
@@ -151,6 +152,33 @@ add_paths_to_joinrel(PlannerInfo *root,
 	extra.mergeclause_list = NIL;
 	extra.sjinfo = sjinfo;
 	extra.param_source_rels = NULL;
+	extra.pgs_mask = joinrel->pgs_mask;
+
+	/*
+	 * Give extensions a chance to take control. In particular, an extension
+	 * might want to modify extra.pgs_mask. It's possible to override pgs_mask
+	 * on a query-wide basis using join_search_hook, or for a particular
+	 * relation using joinrel_setup_hook, but extensions that want to provide
+	 * different advice for the same joinrel based on the choice of innerrel
+	 * and outerrel will need to use this hook.
+	 *
+	 * A very simple way for an extension to use this hook is to set
+	 * extra.pgs_mask &= ~PGS_JOIN_ANY, if it simply doesn't want any of the
+	 * paths generated by this call to add_paths_to_joinrel() to be selected.
+	 * An extension could use this technique to constrain the join order,
+	 * since it could thereby arrange to reject all paths from join orders
+	 * that it does not like. An extension can also selectively clear bits
+	 * from extra.pgs_mask to rule out specific techniques for specific joins,
+	 * or could even set additional bits to re-allow methods disabled at some
+	 * higher level.
+	 *
+	 * NB: Below this point, this function should be careful to reference
+	 * extra.pgs_mask rather than rel->pgs_mask to avoid disregarding any
+	 * changes made by the hook we're about to call.
+	 */
+	if (join_path_setup_hook)
+		join_path_setup_hook(root, joinrel, outerrel, innerrel,
+							 jointype, &extra);
 
 	/*
 	 * See if the inner relation is provably unique for this outer rel.
@@ -210,10 +238,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * Find potential mergejoin clauses.  We can skip this if we are not
 	 * interested in doing a mergejoin.  However, mergejoin may be our only
-	 * way of implementing a full outer join, so override enable_mergejoin if
-	 * it's a full join.
+	 * way of implementing a full outer join, so in that case we don't care
+	 * whether mergejoins are disabled.
 	 */
-	if (enable_mergejoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_MERGEJOIN_ANY) != 0 || jointype == JOIN_FULL)
 		extra.mergeclause_list = select_mergejoin_clauses(root,
 														  joinrel,
 														  outerrel,
@@ -321,10 +349,10 @@ add_paths_to_joinrel(PlannerInfo *root,
 
 	/*
 	 * 4. Consider paths where both outer and inner relations must be hashed
-	 * before being joined.  As above, disregard enable_hashjoin for full
-	 * joins, because there may be no other alternative.
+	 * before being joined.  As above, when it's a full join, we must try this
+	 * even when the path type is disabled, because it may be our only option.
 	 */
-	if (enable_hashjoin || jointype == JOIN_FULL)
+	if ((extra.pgs_mask & PGS_HASHJOIN) != 0 || jointype == JOIN_FULL)
 		hash_inner_and_outer(root, joinrel, outerrel, innerrel,
 							 jointype, &extra);
 
@@ -333,7 +361,7 @@ add_paths_to_joinrel(PlannerInfo *root,
 	 * to the same server and assigned to the same user to check access
 	 * permissions as, give the FDW a chance to push down joins.
 	 */
-	if (joinrel->fdwroutine &&
+	if ((extra.pgs_mask & PGS_FOREIGNJOIN) != 0 && joinrel->fdwroutine &&
 		joinrel->fdwroutine->GetForeignJoinPaths)
 		joinrel->fdwroutine->GetForeignJoinPaths(root, joinrel,
 												 outerrel, innerrel,
@@ -342,8 +370,13 @@ add_paths_to_joinrel(PlannerInfo *root,
 	/*
 	 * 6. Finally, give extensions a chance to manipulate the path list.  They
 	 * could add new paths (such as CustomPaths) by calling add_path(), or
-	 * add_partial_path() if parallel aware.  They could also delete or modify
-	 * paths added by the core code.
+	 * add_partial_path() if parallel aware.
+	 *
+	 * In theory, extensions could also use this hook to delete or modify
+	 * paths added by the core code, but in practice this is difficult to make
+	 * work, since it's too late to get back any paths that have already been
+	 * discarded by add_path() or add_partial_path(). If you're trying to
+	 * suppress paths, consider using join_path_setup_hook instead.
 	 */
 	if (set_join_pathlist_hook)
 		set_join_pathlist_hook(root, joinrel, outerrel, innerrel,
@@ -690,7 +723,7 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel,
 	List	   *ph_lateral_vars;
 
 	/* Obviously not if it's disabled */
-	if (!enable_memoize)
+	if ((extra->pgs_mask & PGS_NESTLOOP_MEMOIZE) == 0)
 		return NULL;
 
 	/*
@@ -845,6 +878,7 @@ try_nestloop_path(PlannerInfo *root,
 				  Path *inner_path,
 				  List *pathkeys,
 				  JoinType jointype,
+				  uint64 nestloop_subtype,
 				  JoinPathExtraData *extra)
 {
 	Relids		required_outer;
@@ -927,6 +961,7 @@ try_nestloop_path(PlannerInfo *root,
 	 * methodology worthwhile.
 	 */
 	initial_cost_nestloop(root, &workspace, jointype,
+						  nestloop_subtype | PGS_CONSIDER_NONPARTIAL,
 						  outer_path, inner_path, extra);
 
 	if (add_path_precheck(joinrel, workspace.disabled_nodes,
@@ -964,6 +999,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 						  Path *inner_path,
 						  List *pathkeys,
 						  JoinType jointype,
+						  uint64 nestloop_subtype,
 						  JoinPathExtraData *extra)
 {
 	JoinCostWorkspace workspace;
@@ -1011,7 +1047,7 @@ try_partial_nestloop_path(PlannerInfo *root,
 	 * Before creating a path, get a quick lower bound on what it is likely to
 	 * cost.  Bail out right away if it looks terrible.
 	 */
-	initial_cost_nestloop(root, &workspace, jointype,
+	initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype,
 						  outer_path, inner_path, extra);
 	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
 								   workspace.total_cost, pathkeys))
@@ -1859,14 +1895,14 @@ match_unsorted_outer(PlannerInfo *root,
 	if (nestjoinOK)
 	{
 		/*
-		 * Consider materializing the cheapest inner path, unless
-		 * enable_material is off or the path in question materializes its
-		 * output anyway.
+		 * Consider materializing the cheapest inner path, unless that is
+		 * disabled or the path in question materializes its output anyway.
 		 */
-		if (enable_material && inner_cheapest_total != NULL &&
+		if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+			inner_cheapest_total != NULL &&
 			!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 			matpath = (Path *)
-				create_material_path(innerrel, inner_cheapest_total);
+				create_material_path(innerrel, inner_cheapest_total, true);
 	}
 
 	foreach(lc1, outerrel->pathlist)
@@ -1909,6 +1945,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  innerpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_PLAIN,
 								  extra);
 
 				/*
@@ -1925,6 +1962,7 @@ match_unsorted_outer(PlannerInfo *root,
 									  mpath,
 									  merge_pathkeys,
 									  jointype,
+									  PGS_NESTLOOP_MEMOIZE,
 									  extra);
 			}
 
@@ -1936,6 +1974,7 @@ match_unsorted_outer(PlannerInfo *root,
 								  matpath,
 								  merge_pathkeys,
 								  jointype,
+								  PGS_NESTLOOP_MATERIALIZE,
 								  extra);
 		}
 
@@ -2052,16 +2091,17 @@ consider_parallel_nestloop(PlannerInfo *root,
 
 	/*
 	 * Consider materializing the cheapest inner path, unless: 1)
-	 * enable_material is off, 2) the cheapest inner path is not
+	 * materialization is disabled here, 2) the cheapest inner path is not
 	 * parallel-safe, 3) the cheapest inner path is parameterized by the outer
 	 * rel, or 4) the cheapest inner path materializes its output anyway.
 	 */
-	if (enable_material && inner_cheapest_total->parallel_safe &&
+	if ((extra->pgs_mask & PGS_NESTLOOP_MATERIALIZE) != 0 &&
+		inner_cheapest_total->parallel_safe &&
 		!PATH_PARAM_BY_REL(inner_cheapest_total, outerrel) &&
 		!ExecMaterializesOutput(inner_cheapest_total->pathtype))
 	{
 		matpath = (Path *)
-			create_material_path(innerrel, inner_cheapest_total);
+			create_material_path(innerrel, inner_cheapest_total, true);
 		Assert(matpath->parallel_safe);
 	}
 
@@ -2091,7 +2131,8 @@ consider_parallel_nestloop(PlannerInfo *root,
 				continue;
 
 			try_partial_nestloop_path(root, joinrel, outerpath, innerpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_PLAIN, extra);
 
 			/*
 			 * Try generating a memoize path and see if that makes the nested
@@ -2102,13 +2143,15 @@ consider_parallel_nestloop(PlannerInfo *root,
 									 extra);
 			if (mpath != NULL)
 				try_partial_nestloop_path(root, joinrel, outerpath, mpath,
-										  pathkeys, jointype, extra);
+										  pathkeys, jointype,
+										  PGS_NESTLOOP_MEMOIZE, extra);
 		}
 
 		/* Also consider materialized form of the cheapest inner path */
 		if (matpath != NULL)
 			try_partial_nestloop_path(root, joinrel, outerpath, matpath,
-									  pathkeys, jointype, extra);
+									  pathkeys, jointype,
+									  PGS_NESTLOOP_MATERIALIZE, extra);
 	}
 }
 
diff --git a/src/backend/optimizer/path/tidpath.c b/src/backend/optimizer/path/tidpath.c
index 3ddbc10bbdf..150115c293f 100644
--- a/src/backend/optimizer/path/tidpath.c
+++ b/src/backend/optimizer/path/tidpath.c
@@ -499,18 +499,19 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	List	   *tidquals;
 	List	   *tidrangequals;
 	bool		isCurrentOf;
+	bool		enabled = (rel->pgs_mask & PGS_TIDSCAN) != 0;
 
 	/*
 	 * If any suitable quals exist in the rel's baserestrict list, generate a
 	 * plain (unparameterized) TidPath with them.
 	 *
-	 * We skip this when enable_tidscan = false, except when the qual is
+	 * We skip this when TID scans are disabled, except when the qual is
 	 * CurrentOfExpr. In that case, a TID scan is the only correct path.
 	 */
 	tidquals = TidQualFromRestrictInfoList(root, rel->baserestrictinfo, rel,
 										   &isCurrentOf);
 
-	if (tidquals != NIL && (enable_tidscan || isCurrentOf))
+	if (tidquals != NIL && (enabled || isCurrentOf))
 	{
 		/*
 		 * This path uses no join clauses, but it could still have required
@@ -532,7 +533,7 @@ create_tidscan_paths(PlannerInfo *root, RelOptInfo *rel)
 	}
 
 	/* Skip the rest if TID scans are disabled. */
-	if (!enable_tidscan)
+	if (!enabled)
 		return false;
 
 	/*
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index e3f27a586ca..66d491ecb10 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -6503,7 +6503,7 @@ Plan *
 materialize_finished_plan(Plan *subplan)
 {
 	Plan	   *matplan;
-	Path		matpath;		/* dummy for result of cost_material */
+	Path		matpath;		/* dummy for cost_material */
 	Cost		initplan_cost;
 	bool		unsafe_initplans;
 
@@ -6525,7 +6525,9 @@ materialize_finished_plan(Plan *subplan)
 	subplan->total_cost -= initplan_cost;
 
 	/* Set cost data */
+	matpath.parent = NULL;
 	cost_material(&matpath,
+				  enable_material,
 				  subplan->disabled_nodes,
 				  subplan->startup_cost,
 				  subplan->total_cost,
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 94e1ac96ed9..36a29355104 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -462,6 +462,53 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 		tuple_fraction = 0.0;
 	}
 
+	/*
+	 * Compute the initial path generation strategy mask.
+	 *
+	 * Some strategies, such as PGS_FOREIGNJOIN, have no corresponding enable_*
+	 * GUC, and so the corresponding bits are always set in the default
+	 * strategy mask.
+	 *
+	 * It may seem surprising that enable_indexscan sets both PGS_INDEXSCAN
+	 * and PGS_INDEXONLYSCAN. However, the historical behavior of this GUC
+	 * corresponds to this exactly: enable_indexscan=off disables both
+	 * index-scan and index-only scan paths, whereas enable_indexonlyscan=off
+	 * converts the index-only scan paths that we would have considered into
+	 * index scan paths.
+	 */
+	glob->default_pgs_mask = PGS_APPEND | PGS_MERGE_APPEND | PGS_FOREIGNJOIN |
+		PGS_GATHER | PGS_CONSIDER_NONPARTIAL;
+	if (enable_tidscan)
+		glob->default_pgs_mask |= PGS_TIDSCAN;
+	if (enable_seqscan)
+		glob->default_pgs_mask |= PGS_SEQSCAN;
+	if (enable_indexscan)
+		glob->default_pgs_mask |= PGS_INDEXSCAN | PGS_INDEXONLYSCAN;
+	if (enable_indexonlyscan)
+		glob->default_pgs_mask |= PGS_CONSIDER_INDEXONLY;
+	if (enable_bitmapscan)
+		glob->default_pgs_mask |= PGS_BITMAPSCAN;
+	if (enable_mergejoin)
+	{
+		glob->default_pgs_mask |= PGS_MERGEJOIN_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_MERGEJOIN_MATERIALIZE;
+	}
+	if (enable_nestloop)
+	{
+		glob->default_pgs_mask |= PGS_NESTLOOP_PLAIN;
+		if (enable_material)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MATERIALIZE;
+		if (enable_memoize)
+			glob->default_pgs_mask |= PGS_NESTLOOP_MEMOIZE;
+	}
+	if (enable_hashjoin)
+		glob->default_pgs_mask |= PGS_HASHJOIN;
+	if (enable_gathermerge)
+		glob->default_pgs_mask |= PGS_GATHER_MERGE;
+	if (enable_partitionwise_join)
+		glob->default_pgs_mask |= PGS_CONSIDER_PARTITIONWISE;
+
 	/* Allow plugins to take control after we've initialized "glob" */
 	if (planner_setup_hook)
 		(*planner_setup_hook) (glob, parse, query_string, &tuple_fraction, es);
@@ -3954,6 +4001,9 @@ make_grouping_rel(PlannerInfo *root, RelOptInfo *input_rel,
 		is_parallel_safe(root, havingQual))
 		grouped_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed */
+	grouped_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the grouped rel.
 	 */
@@ -5348,6 +5398,9 @@ create_ordered_paths(PlannerInfo *root,
 	if (input_rel->consider_parallel && target_parallel_safe)
 		ordered_rel->consider_parallel = true;
 
+	/* Assume that the same path generation strategies are allowed. */
+	ordered_rel->pgs_mask = input_rel->pgs_mask;
+
 	/*
 	 * If the input rel belongs to a single FDW, so does the ordered_rel.
 	 */
@@ -7428,6 +7481,7 @@ create_partial_grouping_paths(PlannerInfo *root,
 											grouped_rel->relids);
 	partially_grouped_rel->consider_parallel =
 		grouped_rel->consider_parallel;
+	partially_grouped_rel->pgs_mask = grouped_rel->pgs_mask;
 	partially_grouped_rel->reloptkind = grouped_rel->reloptkind;
 	partially_grouped_rel->serverid = grouped_rel->serverid;
 	partially_grouped_rel->userid = grouped_rel->userid;
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 33ce34f0088..7dd9a7c4609 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1659,7 +1659,7 @@ create_group_result_path(PlannerInfo *root, RelOptInfo *rel,
  *	  pathnode.
  */
 MaterialPath *
-create_material_path(RelOptInfo *rel, Path *subpath)
+create_material_path(RelOptInfo *rel, Path *subpath, bool enabled)
 {
 	MaterialPath *pathnode = makeNode(MaterialPath);
 
@@ -1678,6 +1678,7 @@ create_material_path(RelOptInfo *rel, Path *subpath)
 	pathnode->subpath = subpath;
 
 	cost_material(&pathnode->path,
+				  enabled,
 				  subpath->disabled_nodes,
 				  subpath->startup_cost,
 				  subpath->total_cost,
@@ -1730,8 +1731,15 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath,
 	pathnode->est_unique_keys = 0.0;
 	pathnode->est_hit_ratio = 0.0;
 
-	/* we should not generate this path type when enable_memoize=false */
-	Assert(enable_memoize);
+	/*
+	 * We should not be asked to generate this path type when memoization is
+	 * disabled, so set our count of disabled nodes equal to the subpath's
+	 * count.
+	 *
+	 * It would be nice to also Assert that memoization is enabled, but the
+	 * value of enable_memoize is not controlling: what we would need to check
+	 * is that the JoinPathExtraData's pgs_mask included PGS_NESTLOOP_MEMOIZE.
+	 */
 	pathnode->path.disabled_nodes = subpath->disabled_nodes;
 
 	/*
@@ -3965,13 +3973,16 @@ reparameterize_path(PlannerInfo *root, Path *path,
 			{
 				MaterialPath *mpath = (MaterialPath *) path;
 				Path	   *spath = mpath->subpath;
+				bool		enabled;
 
 				spath = reparameterize_path(root, spath,
 											required_outer,
 											loop_count);
+				enabled =
+					(mpath->path.disabled_nodes <= spath->disabled_nodes);
 				if (spath == NULL)
 					return NULL;
-				return (Path *) create_material_path(rel, spath);
+				return (Path *) create_material_path(rel, spath, enabled);
 			}
 		case T_Memoize:
 			{
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index bf45c355b77..82a60c3ba6a 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -576,6 +576,9 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 	 * Allow a plugin to editorialize on the info we obtained from the
 	 * catalogs.  Actions might include altering the assumed relation size,
 	 * removing an index, or adding a hypothetical index to the indexlist.
+	 *
+	 * An extension can also modify rel->pgs_mask here to control path
+	 * generation.
 	 */
 	if (get_relation_info_hook)
 		(*get_relation_info_hook) (root, relationObjectId, inhparent, rel);
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 405f4dae109..1d2d7292fe6 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -47,6 +47,9 @@ typedef struct JoinHashEntry
 	RelOptInfo *join_rel;
 } JoinHashEntry;
 
+/* Hook for plugins to get control during joinrel setup */
+joinrel_setup_hook_type joinrel_setup_hook = NULL;
+
 static void build_joinrel_tlist(PlannerInfo *root, RelOptInfo *joinrel,
 								RelOptInfo *input_rel,
 								SpecialJoinInfo *sjinfo,
@@ -225,6 +228,7 @@ build_simple_rel(PlannerInfo *root, int relid, RelOptInfo *parent)
 	rel->consider_startup = (root->tuple_fraction > 0);
 	rel->consider_param_startup = false;	/* might get changed later */
 	rel->consider_parallel = false; /* might get changed later */
+	rel->pgs_mask = root->glob->default_pgs_mask;
 	rel->reltarget = create_empty_pathtarget();
 	rel->pathlist = NIL;
 	rel->ppilist = NIL;
@@ -822,6 +826,7 @@ build_join_rel(PlannerInfo *root,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -934,10 +939,6 @@ build_join_rel(PlannerInfo *root,
 	 */
 	joinrel->has_eclass_joins = has_relevant_eclass_joinclause(root, joinrel);
 
-	/* Store the partition information. */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/*
 	 * Set estimates of the joinrel's size.
 	 */
@@ -963,6 +964,18 @@ build_join_rel(PlannerInfo *root,
 		is_parallel_safe(root, (Node *) joinrel->reltarget->exprs))
 		joinrel->consider_parallel = true;
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Store the partition information. */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* Add the joinrel to the PlannerInfo. */
 	add_join_rel(root, joinrel);
 
@@ -1019,6 +1032,7 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	joinrel->consider_startup = (root->tuple_fraction > 0);
 	joinrel->consider_param_startup = false;
 	joinrel->consider_parallel = false;
+	joinrel->pgs_mask = root->glob->default_pgs_mask;
 	joinrel->reltarget = create_empty_pathtarget();
 	joinrel->pathlist = NIL;
 	joinrel->ppilist = NIL;
@@ -1102,10 +1116,6 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	 */
 	joinrel->has_eclass_joins = parent_joinrel->has_eclass_joins;
 
-	/* Is the join between partitions itself partitioned? */
-	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
-								 restrictlist);
-
 	/* Child joinrel is parallel safe if parent is parallel safe. */
 	joinrel->consider_parallel = parent_joinrel->consider_parallel;
 
@@ -1113,6 +1123,20 @@ build_child_join_rel(PlannerInfo *root, RelOptInfo *outer_rel,
 	set_joinrel_size_estimates(root, joinrel, outer_rel, inner_rel,
 							   sjinfo, restrictlist);
 
+	/*
+	 * Allow a plugin to editorialize on the new joinrel's properties. Actions
+	 * might include altering the size estimate or clearing consider_parallel,
+	 * although the latter would be better done in the parent joinrel rather
+	 * than here.
+	 */
+	if (joinrel_setup_hook)
+		(*joinrel_setup_hook) (root, joinrel, outer_rel, inner_rel, sjinfo,
+							   restrictlist);
+
+	/* Is the join between partitions itself partitioned? */
+	build_joinrel_partition_info(root, joinrel, outer_rel, inner_rel, sjinfo,
+								 restrictlist);
+
 	/* We build the join only once. */
 	Assert(!find_join_rel(root, joinrel->relids));
 
@@ -1602,6 +1626,7 @@ fetch_upper_rel(PlannerInfo *root, UpperRelationKind kind, Relids relids)
 	upperrel = makeNode(RelOptInfo);
 	upperrel->reloptkind = RELOPT_UPPER_REL;
 	upperrel->relids = bms_copy(relids);
+	upperrel->pgs_mask = root->glob->default_pgs_mask;
 
 	/* cheap startup cost is interesting iff not all tuples to be retrieved */
 	upperrel->consider_startup = (root->tuple_fraction > 0);
@@ -2118,7 +2143,7 @@ build_joinrel_partition_info(PlannerInfo *root,
 	PartitionScheme part_scheme;
 
 	/* Nothing to do if partitionwise join technique is disabled. */
-	if (!enable_partitionwise_join)
+	if ((joinrel->pgs_mask & PGS_CONSIDER_PARTITIONWISE) == 0)
 	{
 		Assert(!IS_PARTITIONED_REL(joinrel));
 		return;
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index eacae16b827..7dbc979da12 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -22,6 +22,79 @@
 #include "nodes/parsenodes.h"
 #include "storage/block.h"
 
+/*
+ * Path generation strategies.
+ *
+ * These constants are used to specify the set of strategies that the planner
+ * should use, either for the query as a whole or for a specific baserel or
+ * joinrel. The various planner-related enable_* GUCs are used to set the
+ * PlannerGlobal's default_pgs_mask, and that in turn is used to set each
+ * RelOptInfo's pgs_mask. In both cases, extensions can use hooks to modify the
+ * default value.  Not every strategy listed here has a corresponding enable_*
+ * GUC; those that don't are always allowed unless disabled by an extension.
+ * Not all strategies are relevant for every RelOptInfo; e.g. PGS_SEQSCAN
+ * doesn't affect joinrels one way or the other.
+ *
+ * In most cases, disabling a path generation strategy merely means that any
+ * paths generated using that strategy are marked as disabled, but in some
+ * cases, path generation is skipped altogether. The latter strategy is only
+ * permissible when it can't result in planner failure -- for instance, we
+ * couldn't do this for sequential scans on a plain rel, because there might
+ * not be any other possible path. Nevertheless, the behaviors in each
+ * individual case are to some extent the result of historical accident,
+ * chosen to match the preexisting behaviors of the enable_* GUCs.
+ *
+ * In a few cases, we have more than one bit for the same strategy, controlling
+ * different aspects of the planner behavior. When PGS_CONSIDER_INDEXONLY is
+ * unset, we don't even consider index-only scans, and any such scans that
+ * would have been generated become index scans instead. On the other hand,
+ * unsetting PGS_INDEXSCAN or PGS_INDEXONLYSCAN causes generated paths of the
+ * corresponding types to be marked as disabled. Similarly, unsetting
+ * PGS_CONSIDER_PARTITIONWISE prevents any sort of thinking about partitionwise
+ * joins for the current rel, which incidentally will preclude higher-level
+ * joinrels from building parititonwise paths using paths taken from the
+ * current rel's children. On the other hand, unsetting PGS_APPEND or
+ * PGS_MERGE_APPEND will only arrange to disable paths of the corresponding
+ * types if they are generated at the level of the current rel.
+ *
+ * Finally, unsetting PGS_CONSIDER_NONPARTIAL disables all non-partial paths
+ * except those that use Gather or Gather Merge. In most other cases, a
+ * plugin can nudge the planner toward a particular strategy by disabling
+ * all of the others, but that doesn't work here: unsetting PGS_SEQSCAN,
+ * for instance, would disable both partial and non-partial sequential scans.
+ */
+#define PGS_SEQSCAN					0x00000001
+#define PGS_INDEXSCAN				0x00000002
+#define PGS_INDEXONLYSCAN			0x00000004
+#define PGS_BITMAPSCAN				0x00000008
+#define PGS_TIDSCAN					0x00000010
+#define PGS_FOREIGNJOIN				0x00000020
+#define PGS_MERGEJOIN_PLAIN			0x00000040
+#define PGS_MERGEJOIN_MATERIALIZE	0x00000080
+#define PGS_NESTLOOP_PLAIN			0x00000100
+#define PGS_NESTLOOP_MATERIALIZE	0x00000200
+#define PGS_NESTLOOP_MEMOIZE		0x00000400
+#define PGS_HASHJOIN				0x00000800
+#define PGS_APPEND					0x00001000
+#define PGS_MERGE_APPEND			0x00002000
+#define PGS_GATHER					0x00004000
+#define PGS_GATHER_MERGE			0x00008000
+#define PGS_CONSIDER_INDEXONLY		0x00010000
+#define PGS_CONSIDER_PARTITIONWISE	0x00020000
+#define PGS_CONSIDER_NONPARTIAL		0x00040000
+
+/*
+ * Convenience macros for useful combination of the bits defined above.
+ */
+#define PGS_SCAN_ANY		\
+	(PGS_SEQSCAN | PGS_INDEXSCAN | PGS_INDEXONLYSCAN | PGS_BITMAPSCAN | \
+	 PGS_TIDSCAN)
+#define PGS_MERGEJOIN_ANY	\
+	(PGS_MERGEJOIN_PLAIN | PGS_MERGEJOIN_MATERIALIZE)
+#define PGS_NESTLOOP_ANY	\
+	(PGS_NESTLOOP_PLAIN | PGS_NESTLOOP_MATERIALIZE | PGS_NESTLOOP_MEMOIZE)
+#define PGS_JOIN_ANY		\
+	(PGS_FOREIGNJOIN | PGS_MERGEJOIN_ANY | PGS_NESTLOOP_ANY | PGS_HASHJOIN)
 
 /*
  * Relids
@@ -186,6 +259,9 @@ typedef struct PlannerGlobal
 	/* worst PROPARALLEL hazard level */
 	char		maxParallelHazard;
 
+	/* mask of allowed path generation strategies */
+	uint64		default_pgs_mask;
+
 	/* partition descriptors */
 	PartitionDirectory partition_directory pg_node_attr(read_write_ignore);
 
@@ -939,7 +1015,7 @@ typedef struct RelOptInfo
 	Cardinality rows;
 
 	/*
-	 * per-relation planner control flags
+	 * per-relation planner control
 	 */
 	/* keep cheap-startup-cost paths? */
 	bool		consider_startup;
@@ -947,6 +1023,8 @@ typedef struct RelOptInfo
 	bool		consider_param_startup;
 	/* consider parallel paths? */
 	bool		consider_parallel;
+	/* path generation strategy mask */
+	uint64		pgs_mask;
 
 	/*
 	 * default result targetlist for Paths scanning this relation; list of
@@ -3506,6 +3584,7 @@ typedef struct SemiAntiJoinFactors
  * sjinfo is extra info about special joins for selectivity estimation
  * semifactors is as shown above (only valid for SEMI/ANTI/inner_unique joins)
  * param_source_rels are OK targets for parameterization of result paths
+ * pgs_mask is a bitmask of PGS_* constants to limit the join strategy
  */
 typedef struct JoinPathExtraData
 {
@@ -3515,6 +3594,7 @@ typedef struct JoinPathExtraData
 	SpecialJoinInfo *sjinfo;
 	SemiAntiJoinFactors semifactors;
 	Relids		param_source_rels;
+	uint64		pgs_mask;
 } JoinPathExtraData;
 
 /*
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index b523bcda8f3..2d80462bece 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -125,7 +125,7 @@ extern void cost_merge_append(Path *path, PlannerInfo *root,
 							  Cost input_startup_cost, Cost input_total_cost,
 							  double tuples);
 extern void cost_material(Path *path,
-						  int input_disabled_nodes,
+						  bool enabled, int input_disabled_nodes,
 						  Cost input_startup_cost, Cost input_total_cost,
 						  double tuples, int width);
 extern void cost_agg(Path *path, PlannerInfo *root,
@@ -148,7 +148,7 @@ extern void cost_group(Path *path, PlannerInfo *root,
 					   double input_tuples);
 extern void initial_cost_nestloop(PlannerInfo *root,
 								  JoinCostWorkspace *workspace,
-								  JoinType jointype,
+								  JoinType jointype, uint64 enable_mask,
 								  Path *outer_path, Path *inner_path,
 								  JoinPathExtraData *extra);
 extern void final_cost_nestloop(PlannerInfo *root, NestPath *path,
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index dbf4702acc9..123b78cbf11 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -17,6 +17,14 @@
 #include "nodes/bitmapset.h"
 #include "nodes/pathnodes.h"
 
+/* Hook for plugins to get control during joinrel setup */
+typedef void (*joinrel_setup_hook_type) (PlannerInfo *root,
+										 RelOptInfo *joinrel,
+										 RelOptInfo *outer_rel,
+										 RelOptInfo *inner_rel,
+										 SpecialJoinInfo *sjinfo,
+										 List *restrictlist);
+extern PGDLLIMPORT joinrel_setup_hook_type joinrel_setup_hook;
 
 /*
  * prototypes for pathnode.c
@@ -85,7 +93,8 @@ extern GroupResultPath *create_group_result_path(PlannerInfo *root,
 												 RelOptInfo *rel,
 												 PathTarget *target,
 												 List *havingqual);
-extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath);
+extern MaterialPath *create_material_path(RelOptInfo *rel, Path *subpath,
+										  bool enabled);
 extern MemoizePath *create_memoize_path(PlannerInfo *root,
 										RelOptInfo *rel,
 										Path *subpath,
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index f6a62df0b43..61c1607f872 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -28,7 +28,14 @@ extern PGDLLIMPORT int min_parallel_table_scan_size;
 extern PGDLLIMPORT int min_parallel_index_scan_size;
 extern PGDLLIMPORT bool enable_group_by_reordering;
 
-/* Hook for plugins to get control in set_rel_pathlist() */
+/* Hooks for plugins to get control in set_rel_pathlist() */
+typedef void (*join_path_setup_hook_type) (PlannerInfo *root,
+										   RelOptInfo *joinrel,
+										   RelOptInfo *outerrel,
+										   RelOptInfo *innerrel,
+										   JoinType jointype,
+										   JoinPathExtraData *extra);
+extern PGDLLIMPORT join_path_setup_hook_type join_path_setup_hook;
 typedef void (*set_rel_pathlist_hook_type) (PlannerInfo *root,
 											RelOptInfo *rel,
 											Index rti,
-- 
2.51.0



  [application/octet-stream] v7-0005-WIP-Add-pg_plan_advice-contrib-module.patch (385.8K, 6-v7-0005-WIP-Add-pg_plan_advice-contrib-module.patch)
  download | inline diff:
From 1a064c4bff144d7397a438d9d4c13e4717d4aae2 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 4 Nov 2025 14:45:31 -0500
Subject: [PATCH v7 5/5] WIP: Add pg_plan_advice contrib module.

Provide a facility that (1) can be used to stabilize certain plan choices
so that the planner cannot reverse course without authorization and
(2) can be used by knowledgeable users to insist on plan choices contrary
to what the planner believes best. In both cases, terrible outcomes are
possible: users should think twice and perhaps three times before
constraining the planner's ability to do as it thinks best; nevertheless,
there are problems that are much more easily solved with these facilities
than without them.

We take the approach of analyzing a finished plan to produce textual
output, which we call "plan advice", that describes key decisions made
during plan; if that plan advice is provided during future planning
cycles, it will force those key decisions to be made in the same way.
Not all planner decisions can be controlled using advice; for example,
decisions about how to perform aggregation are currently out of scope,
as is choice of sort order. Plan advice can also be edited by the user,
or even written from scratch in simple cases, making it possible to
generate outcomes that the planner would not have produced. Partial
advice can be provided to control some planner outcomes but not others.

Currently, plan advice is focused only on specific outcomes, such as
the choice to use a sequential scan for a particular relation, and not
on estimates that might contribute to those outcomes, such as a
possibly-incorrect selectivity estimate. While it would be useful to
users to be able to provide plan advice that affects selectivity
estimates or other aspects of costing, that is out of scope for this
commit.

For more details, see contrib/pg_plan_advice/README.

NOTE: This code is just a proof of concept. A bunch of things don't
work and a lot of the code needs cleanup. It has no SGML documentation
and not enough test cases, and some of the existing test cases don't
do as we would hope. Known problems are called out by XXX.
---
 contrib/Makefile                              |    1 +
 contrib/meson.build                           |    1 +
 contrib/pg_plan_advice/.gitignore             |    3 +
 contrib/pg_plan_advice/Makefile               |   50 +
 contrib/pg_plan_advice/README                 |  271 +++
 contrib/pg_plan_advice/expected/gather.out    |  344 ++++
 .../pg_plan_advice/expected/join_order.out    |  292 +++
 .../pg_plan_advice/expected/join_strategy.out |  297 +++
 .../expected/local_collector.out              |   65 +
 .../pg_plan_advice/expected/partitionwise.out |  243 +++
 contrib/pg_plan_advice/expected/scan.out      |  757 +++++++
 contrib/pg_plan_advice/expected/syntax.out    |  162 ++
 contrib/pg_plan_advice/meson.build            |   70 +
 .../pg_plan_advice/pg_plan_advice--1.0.sql    |   42 +
 contrib/pg_plan_advice/pg_plan_advice.c       |  455 +++++
 contrib/pg_plan_advice/pg_plan_advice.control |    5 +
 contrib/pg_plan_advice/pg_plan_advice.h       |   39 +
 contrib/pg_plan_advice/pgpa_ast.c             |  392 ++++
 contrib/pg_plan_advice/pgpa_ast.h             |  204 ++
 contrib/pg_plan_advice/pgpa_collector.c       |  637 ++++++
 contrib/pg_plan_advice/pgpa_collector.h       |   18 +
 contrib/pg_plan_advice/pgpa_identifier.c      |  476 +++++
 contrib/pg_plan_advice/pgpa_identifier.h      |   52 +
 contrib/pg_plan_advice/pgpa_join.c            |  629 ++++++
 contrib/pg_plan_advice/pgpa_join.h            |  105 +
 contrib/pg_plan_advice/pgpa_output.c          |  628 ++++++
 contrib/pg_plan_advice/pgpa_output.h          |   22 +
 contrib/pg_plan_advice/pgpa_parser.y          |  337 ++++
 contrib/pg_plan_advice/pgpa_planner.c         | 1764 +++++++++++++++++
 contrib/pg_plan_advice/pgpa_planner.h         |   17 +
 contrib/pg_plan_advice/pgpa_scan.c            |  303 +++
 contrib/pg_plan_advice/pgpa_scan.h            |   85 +
 contrib/pg_plan_advice/pgpa_scanner.l         |  302 +++
 contrib/pg_plan_advice/pgpa_trove.c           |  492 +++++
 contrib/pg_plan_advice/pgpa_trove.h           |  113 ++
 contrib/pg_plan_advice/pgpa_walker.c          |  965 +++++++++
 contrib/pg_plan_advice/pgpa_walker.h          |  141 ++
 contrib/pg_plan_advice/sql/gather.sql         |   79 +
 contrib/pg_plan_advice/sql/join_order.sql     |   96 +
 contrib/pg_plan_advice/sql/join_strategy.sql  |   76 +
 .../pg_plan_advice/sql/local_collector.sql    |   41 +
 contrib/pg_plan_advice/sql/partitionwise.sql  |   78 +
 contrib/pg_plan_advice/sql/scan.sql           |  195 ++
 contrib/pg_plan_advice/sql/syntax.sql         |   57 +
 contrib/pg_plan_advice/t/001_regress.pl       |  147 ++
 src/tools/pgindent/typedefs.list              |   38 +
 46 files changed, 11586 insertions(+)
 create mode 100644 contrib/pg_plan_advice/.gitignore
 create mode 100644 contrib/pg_plan_advice/Makefile
 create mode 100644 contrib/pg_plan_advice/README
 create mode 100644 contrib/pg_plan_advice/expected/gather.out
 create mode 100644 contrib/pg_plan_advice/expected/join_order.out
 create mode 100644 contrib/pg_plan_advice/expected/join_strategy.out
 create mode 100644 contrib/pg_plan_advice/expected/local_collector.out
 create mode 100644 contrib/pg_plan_advice/expected/partitionwise.out
 create mode 100644 contrib/pg_plan_advice/expected/scan.out
 create mode 100644 contrib/pg_plan_advice/expected/syntax.out
 create mode 100644 contrib/pg_plan_advice/meson.build
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice--1.0.sql
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.c
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.control
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.h
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.c
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.h
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.c
 create mode 100644 contrib/pg_plan_advice/pgpa_collector.h
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.c
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.h
 create mode 100644 contrib/pg_plan_advice/pgpa_join.c
 create mode 100644 contrib/pg_plan_advice/pgpa_join.h
 create mode 100644 contrib/pg_plan_advice/pgpa_output.c
 create mode 100644 contrib/pg_plan_advice/pgpa_output.h
 create mode 100644 contrib/pg_plan_advice/pgpa_parser.y
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.c
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.c
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scanner.l
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.c
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.h
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.c
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.h
 create mode 100644 contrib/pg_plan_advice/sql/gather.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_order.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_strategy.sql
 create mode 100644 contrib/pg_plan_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_plan_advice/sql/partitionwise.sql
 create mode 100644 contrib/pg_plan_advice/sql/scan.sql
 create mode 100644 contrib/pg_plan_advice/sql/syntax.sql
 create mode 100644 contrib/pg_plan_advice/t/001_regress.pl

diff --git a/contrib/Makefile b/contrib/Makefile
index 2f0a88d3f77..dd04c20acd2 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -34,6 +34,7 @@ SUBDIRS = \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
+		pg_plan_advice \
 		pg_prewarm	\
 		pg_stat_statements \
 		pg_surgery	\
diff --git a/contrib/meson.build b/contrib/meson.build
index ed30ee7d639..cb718dbdac0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -48,6 +48,7 @@ subdir('pgcrypto')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
+subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
 subdir('pg_stat_statements')
diff --git a/contrib/pg_plan_advice/.gitignore b/contrib/pg_plan_advice/.gitignore
new file mode 100644
index 00000000000..19a14253019
--- /dev/null
+++ b/contrib/pg_plan_advice/.gitignore
@@ -0,0 +1,3 @@
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
new file mode 100644
index 00000000000..1d4c559aed8
--- /dev/null
+++ b/contrib/pg_plan_advice/Makefile
@@ -0,0 +1,50 @@
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_plan_advice.o \
+	pgpa_ast.o \
+	pgpa_collector.o \
+	pgpa_identifier.o \
+	pgpa_join.o \
+	pgpa_output.o \
+	pgpa_parser.o \
+	pgpa_planner.o \
+	pgpa_scan.o \
+	pgpa_scanner.o \
+	pgpa_trove.o \
+	pgpa_walker.o
+
+EXTENSION = pg_plan_advice
+DATA = pg_plan_advice--1.0.sql
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise scan
+TAP_TESTS = 1
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+# required for 001_regress.pl
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+	touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
new file mode 100644
index 00000000000..7cd3abddb97
--- /dev/null
+++ b/contrib/pg_plan_advice/README
@@ -0,0 +1,271 @@
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planing
+cycle, each piece of advice should be guaranteed to apply to the exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should succesfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it is appears to be good advice or bad advice.  If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+   e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously.  Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables.  When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by curly brances, e.g. SOME_SORT_OF_SCAN(foo (bar baz)) would mean that the
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+Bitmap heap scans allow for a more complicated index specification. For
+example, BITMAP_HEAP_SCAN(foo &&(foo_a_idx ||(foo_b_idx foo_c_idx))) says
+that foo should be scanned using a BitmapHeapScan over a BitmapAnd between
+foo_a_idx and the result of a BitmapOr between foo_b_idx and foo_c_idx.
+
+XXX: Currently, BITMAP_HEAP_SCAN does not enforce the index specification,
+because the available hooks are insufficient to do so. It's possible that
+this should be changed to exclude the index specification altogether and
+simply insist that some sort of bitmap heap scan is used; alternatively,
+we need better hooks.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ORDER() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructured. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Strategy Advice
+====================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+strategy advice; no join strategy advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join strategy advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the planner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join strategy advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join strategy advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join strategy advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+XXX: Right semijoins haven't been properly thought through. The associated
+code probably just doesn't work.
+
+XXX: Semijoin uniqueness advice has no automated tests and need substantially
+more manual testing.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advise can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constraint the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the outer
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constraint won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
new file mode 100644
index 00000000000..bd4f4c24e27
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -0,0 +1,344 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Sort Key: f.dim_id
+   ->  Gather
+         Workers Planned: 1
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((d d/d.d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER((d d/d.d)) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(17 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(d)
+   NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(d)
+   NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   NO_GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Disabled: true
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(14 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
new file mode 100644
index 00000000000..e87652370c3
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -0,0 +1,292 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   HASH_JOIN(d1 d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (d1.id = f.dim1_id)
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Hash
+               ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 f d2)
+   HASH_JOIN(f d2)
+   SEQ_SCAN(d1 f d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN(d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Disabled: true
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_PLAIN(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
new file mode 100644
index 00000000000..71ee26a337a
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -0,0 +1,297 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Disabled: true
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Materialize
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MATERIALIZE(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Memoize
+         Cache Key: f.dim_id
+         Cache Mode: logical
+         ->  Index Scan using join_dim_pkey on join_dim d
+               Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MEMOIZE(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN              
+-------------------------------------
+ Hash Join
+   Hash Cond: (d.id = f.dim_id)
+   ->  Seq Scan on join_dim d
+   ->  Hash
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   HASH_JOIN(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Materialize
+         ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_MATERIALIZE(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_dim d
+   ->  Materialize
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MATERIALIZE(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Memoize
+         Cache Key: d.id
+         Cache Mode: logical
+         ->  Index Scan using join_fact_dim_id on join_fact f
+               Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MEMOIZE(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+         Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_PLAIN(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(13 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/local_collector.out b/contrib/pg_plan_advice/expected/local_collector.out
new file mode 100644
index 00000000000..56f554bf239
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/local_collector.out
@@ -0,0 +1,65 @@
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
new file mode 100644
index 00000000000..df0f05531d5
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -0,0 +1,243 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt2.id)
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1b pt1_2
+               Filter: (val1 = 1)
+         ->  Seq Scan on pt1c pt1_3
+               Filter: (val1 = 1)
+   ->  Hash
+         ->  Hash Join
+               Hash Cond: (pt2.id = pt3.id)
+               ->  Append
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+               ->  Hash
+                     ->  Append
+                           ->  Seq Scan on pt3a pt3_1
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3b pt3_2
+                                 Filter: (val3 = 1)
+                           ->  Seq Scan on pt3c pt3_3
+                                 Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE(pt1) /* matched */
+   PARTITIONWISE(pt2) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 (pt2 pt3))
+   HASH_JOIN(pt3 pt3)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE(pt1 pt2 pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(40 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt3.id)
+   ->  Append
+         ->  Hash Join
+               Hash Cond: (pt1_1.id = pt2_1.id)
+               ->  Seq Scan on pt1a pt1_1
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_2.id = pt2_2.id)
+               ->  Seq Scan on pt1b pt1_2
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_3.id = pt2_3.id)
+               ->  Seq Scan on pt1c pt1_3
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+   ->  Hash
+         ->  Append
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3b pt3_2
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3c pt3_3
+                     Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+   JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+   JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+   JOIN_ORDER({pt1 pt2} pt3)
+   HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+   SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+    pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE((pt1 pt2) pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+         ->  Seq Scan on pt1b pt1_2
+         ->  Seq Scan on pt1c pt1_3
+   ->  Append
+         ->  Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+               Index Cond: (id = pt1.id)
+         ->  Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+               Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 ptmismatch)
+   NESTED_LOOP_PLAIN(ptmismatch)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+    ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+   PARTITIONWISE(pt1 ptmismatch)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+    ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
new file mode 100644
index 00000000000..a80de78d823
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -0,0 +1,757 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+       QUERY PLAN        
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                     QUERY PLAN                     
+----------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+            QUERY PLAN             
+-----------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_b) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+   NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a > 0)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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 > 0;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a > 0)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (a > 0)
+   ->  Bitmap Index Scan on scan_table_pkey
+         Index Cond: (a > 0)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(9 rows)
+
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_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:
+   TID_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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table cilbup.scan_table_pkey) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, conflicting */
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched, conflicting */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Nested Loop Left Join
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s s#2)
+   INDEX_SCAN(s public.scan_table_pkey s#2 public.scan_table_pkey)
+   NO_GATHER(s s#2)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop Left Join
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s#2)
+   HASH_JOIN(s)
+   SEQ_SCAN(s)
+   INDEX_SCAN(s#2 public.scan_table_pkey)
+   NO_GATHER(s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s)
+   HASH_JOIN(s#2)
+   SEQ_SCAN(s#2)
+   INDEX_SCAN(s public.scan_table_pkey)
+   NO_GATHER(s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   HASH_JOIN(s s#2)
+   SEQ_SCAN(s s#2)
+   NO_GATHER(s s#2)
+(17 rows)
+
+COMMIT;
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(s@x)
+(5 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(s@unnamed_subquery)
+(5 rows)
+
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(s@unnamed_subquery)
+(7 rows)
+
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+          QUERY PLAN           
+-------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@x)
+   NO_GATHER(s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(s@unnamed_subquery)
+(7 rows)
+
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery)
+   NO_GATHER(s@unnamed_subquery)
+(7 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/syntax.out b/contrib/pg_plan_advice/expected/syntax.out
new file mode 100644
index 00000000000..eec02980896
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/syntax.out
@@ -0,0 +1,162 @@
+LOAD 'pg_plan_advice';
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+(1 row)
+
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+(2 rows)
+
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   SEQ_SCAN(x) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   SEQ_SCAN(x@y) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   SEQ_SCAN(x#2) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   SEQ_SCAN(x/y) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   SEQ_SCAN(x/y.z) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   SEQ_SCAN(x#2/y.z@t) /* not matched */
+(3 rows)
+
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQUENTIAL_SCAN(x)"
+DETAIL:  Could not parse advice: syntax error at or near "SEQUENTIAL_SCAN"
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN"
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(""
+DETAIL:  Could not parse advice: unterminated quoted identifier at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("")';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("")"
+DETAIL:  Could not parse advice: zero-length delimited identifier at or near """
+SET pg_plan_advice.advice = 'SEQ_SCAN("a"';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("a""
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(#"
+DETAIL:  Could not parse advice: syntax error at or near "#"
+SET pg_plan_advice.advice = '()';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "()"
+DETAIL:  Could not parse advice: syntax error at or near "("
+SET pg_plan_advice.advice = '123';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "123"
+DETAIL:  Could not parse advice: syntax error at or near "123"
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+(1 row)
+
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   HASH_JOIN(_) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   HASH_JOIN(y) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+ Supplied Plan Advice:
+   HASH_JOIN(y/z) /* not matched */
+(3 rows)
+
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "JOIN_ORDER("fOO") /* oops"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+EXPLAIN SELECT 1;
+                QUERY PLAN                
+------------------------------------------
+ Result  (cost=0.00..0.01 rows=1 width=4)
+(1 row)
+
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*/* stuff */*/"
+DETAIL:  Could not parse advice: syntax error at or near "*"
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN(a)"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN((a))"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
new file mode 100644
index 00000000000..3452e5ad48e
--- /dev/null
+++ b/contrib/pg_plan_advice/meson.build
@@ -0,0 +1,70 @@
+# Copyright (c) 2022-2024, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+  'pg_plan_advice.c',
+  'pgpa_ast.c',
+  'pgpa_collector.c',
+  'pgpa_identifier.c',
+  'pgpa_join.c',
+  'pgpa_output.c',
+  'pgpa_planner.c',
+  'pgpa_scan.c',
+  'pgpa_trove.c',
+  'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+  input: 'pgpa_scanner.l',
+  output: 'pgpa_scanner.c',
+  command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+  input: 'pgpa_parser.y',
+  kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+  pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_plan_advice',
+    '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice = shared_module('pg_plan_advice',
+  pg_plan_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_data(
+  'pg_plan_advice--1.0.sql',
+  'pg_plan_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gather',
+      'join_order',
+      'join_strategy',
+      'local_collector',
+      'partitionwise',
+      'scan',
+      'syntax',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_regress.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice--1.0.sql b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
new file mode 100644
index 00000000000..29f4f224864
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice--1.0.sql
@@ -0,0 +1,42 @@
+/* contrib/pg_plan_advice/pg_plan_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_plan_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_plan_advice/pg_plan_advice.c b/contrib/pg_plan_advice/pg_plan_advice.c
new file mode 100644
index 00000000000..865931c960f
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.c
@@ -0,0 +1,455 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ *	  main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static pgpa_shared_state *pgpa_state = NULL;
+static dsa_area *pgpa_dsa_area = NULL;
+
+/* GUC variables */
+char	   *pg_plan_advice_advice = NULL;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+int			pg_plan_advice_local_collection_limit = 0;
+int			pg_plan_advice_shared_collection_limit = 0;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int	es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+												  DefElem *opt,
+												  ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+												 IntoClause *into,
+												 ExplainState *es,
+												 const char *queryString,
+												 ParamListInfo params,
+												 QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+											 GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("pg_plan_advice.advice",
+							   "advice to apply during query planning",
+							   NULL,
+							   &pg_plan_advice_advice,
+							   NULL,
+							   PGC_USERSET,
+							   0,
+							   pg_plan_advice_advice_check_hook,
+							   NULL,
+							   NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+							 "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+							 NULL,
+							 &pg_plan_advice_always_explain_supplied_advice,
+							 true,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_plan_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomIntVariable("pg_plan_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_plan_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_plan_advice");
+
+	/* Get an ID that we can use to cache data in an ExplainState. */
+	es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+	/* Register the new EXPLAIN options implemented by this module. */
+	RegisterExtensionExplainOption("plan_advice",
+								   pg_plan_advice_explain_option_handler);
+
+	/* Install hooks */
+	pgpa_planner_install_hooks();
+	prev_explain_per_plan = explain_per_plan_hook;
+	explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgpa_init_shared_state(void *ptr)
+{
+	pgpa_shared_state *state = (pgpa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock, LWLockNewTrancheId("pg_plan_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_plan_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+	if (pgpa_memory_context == NULL)
+		pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_plan_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgpa_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ *
+ * Along the way, make sure the relevant LWLock tranches are registered.
+ */
+pgpa_shared_state *
+pg_plan_advice_attach(void)
+{
+	if (pgpa_state == NULL)
+	{
+		bool		found;
+
+		pgpa_state =
+			GetNamedDSMSegment("pg_plan_advice", sizeof(pgpa_shared_state),
+							   pgpa_init_shared_state, &found);
+	}
+
+	return pgpa_state;
+}
+
+/*
+ * Return a pointer to pg_plan_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_plan_advice_dsa_area(void)
+{
+	if (pgpa_dsa_area == NULL)
+	{
+		pgpa_shared_state *state = pg_plan_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgpa_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgpa_dsa_area);
+			state->area = dsa_get_handle(pgpa_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgpa_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgpa_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgpa_dsa_area;
+}
+
+/*
+ * Was the PLAN_ADVICE option specified and not set to false?
+ */
+bool
+pg_plan_advice_should_explain(ExplainState *es)
+{
+	bool	   *plan_advice = NULL;
+
+	if (es != NULL)
+		plan_advice = GetExplainExtensionState(es, es_extension_id);
+	return plan_advice != NULL && *plan_advice;
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+									  ParseState *pstate)
+{
+	bool	   *plan_advice;
+
+	plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+	if (plan_advice == NULL)
+	{
+		plan_advice = palloc0_object(bool);
+		SetExplainExtensionState(es, es_extension_id, plan_advice);
+	}
+
+	*plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+									  char *value)
+{
+	char	   *s;
+
+	/* For non-text formats, it's best not to add any special handling. */
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		ExplainPropertyText(qlabel, value, es);
+		return;
+	}
+
+	/* In text format, if there is no data, display nothing. */
+	if (*qlabel == '\0')
+		return;
+
+	/*
+	 * It looks nicest to indent each line of the advice separately, beginning
+	 * on the line below the label.
+	 */
+	ExplainIndentText(es);
+	appendStringInfo(es->str, "%s:\n", qlabel);
+	es->indent++;
+	while ((s = strchr(value, '\n')) != NULL)
+	{
+		ExplainIndentText(es);
+		appendBinaryStringInfo(es->str, value, (s - value) + 1);
+		value = s + 1;
+	}
+
+	/* Don't interpret a terminal newline as a request for an empty line. */
+	if (*value != '\0')
+	{
+		ExplainIndentText(es);
+		appendStringInfo(es->str, "%s\n", value);
+	}
+
+	es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		appendStringInfo(&buf, "%s /* ", item->defname);
+		if ((flags & PGPA_TE_MATCH_FULL) != 0)
+		{
+			Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+			appendStringInfo(&buf, "matched");
+		}
+		else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+			appendStringInfo(&buf, "partially matched");
+		else
+			appendStringInfo(&buf, "not matched");
+		if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+			appendStringInfo(&buf, ", inapplicable");
+		if ((flags & PGPA_TE_CONFLICTING) != 0)
+			appendStringInfo(&buf, ", conflicting");
+		if ((flags & PGPA_TE_FAILED) != 0)
+			appendStringInfo(&buf, ", failed");
+		appendStringInfo(&buf, " */\n");
+	}
+
+	pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+										  buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+									 IntoClause *into,
+									 ExplainState *es,
+									 const char *queryString,
+									 ParamListInfo params,
+									 QueryEnvironment *queryEnv)
+{
+	bool		should_explain;
+	DefElem    *pgpa_item;
+	List	   *pgpa_list;
+
+	if (prev_explain_per_plan)
+		prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+							  queryEnv);
+
+	/* Should an advice string be part of the EXPLAIN output? */
+	should_explain = pg_plan_advice_should_explain(es);
+
+	/* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+	pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+										"pg_plan_advice");
+	pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+	/*
+	 * By default, if there is a record of attempting to apply advice during
+	 * query planning, we always output that information, but the user can set
+	 * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+	 * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+	 * was specified and not set to false.
+	 *
+	 * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+	 * statement -- the application of query advice may not have been
+	 * recorded, and therefore this won't be able to show anything.
+	 */
+	if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+							  should_explain))
+	{
+		DefElem    *feedback;
+
+		feedback = find_defelem_by_defname(pgpa_list, "feedback");
+		if (feedback != NULL)
+			pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+	}
+
+	/*
+	 * If the PLAN_ADVICE option was specified -- and not sent to FALSE --
+	 * show generated advice.
+	 */
+	if (should_explain)
+	{
+		DefElem    *advice_string_item;
+		char	   *advice_string = NULL;
+
+		advice_string_item =
+			find_defelem_by_defname(pgpa_list, "advice_string");
+		if (advice_string_item != NULL)
+		{
+			/* Advice has already been generated; we can reuse it. */
+			advice_string = strVal(advice_string_item->arg);
+		}
+		if (advice_string != NULL && advice_string[0] != '\0')
+			pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+												  advice_string);
+	}
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+	MemoryContext oldcontext;
+	MemoryContext tmpcontext;
+	char	   *error;
+
+	if (*newval == NULL)
+		return true;
+
+	tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+									   "pg_plan_advice.advice",
+									   ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+	/*
+	 * It would be nice to save the parse tree that we construct here for
+	 * eventual use when planning with this advice, but *extra can only point
+	 * to a single guc_malloc'd chunk, and our parse tree involves an
+	 * arbitrary number of memory allocations.
+	 */
+	(void) pgpa_parse(*newval, &error);
+
+	if (error != NULL)
+	{
+		GUC_check_errdetail("Could not parse advice: %s", error);
+		return false;
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+	MemoryContextDelete(tmpcontext);
+
+	return true;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.control b/contrib/pg_plan_advice/pg_plan_advice.control
new file mode 100644
index 00000000000..aa6fdc9e7b2
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.control
@@ -0,0 +1,5 @@
+# pg_plan_advice extension
+comment = 'help the planner get the right plan'
+default_version = '1.0'
+module_pathname = '$libdir/pg_plan_advice'
+relocatable = true
diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
new file mode 100644
index 00000000000..02e65a3382b
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -0,0 +1,39 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ *	  main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "commands/explain_state.h"
+#include "nodes/plannodes.h"
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgpa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgpa_shared_state;
+
+/* GUC variables */
+extern int	pg_plan_advice_local_collection_limit;
+extern int	pg_plan_advice_shared_collection_limit;
+extern char *pg_plan_advice_advice;
+
+/* Function prototypes */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern pgpa_shared_state *pg_plan_advice_attach(void);
+extern dsa_area *pg_plan_advice_dsa_area(void);
+extern bool pg_plan_advice_should_explain(ExplainState *es);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
new file mode 100644
index 00000000000..5f822bce036
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -0,0 +1,392 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ *	  additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+										  pgpa_advice_target *target,
+										  bool *rids_used);
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+	switch (advice_tag)
+	{
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_FOREIGN_JOIN:
+			return "FOREIGN_JOIN";
+		case PGPA_TAG_GATHER:
+			return "GATHER";
+		case PGPA_TAG_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPA_TAG_HASH_JOIN:
+			return "HASH_JOIN";
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_TAG_INDEX_SCAN:
+			return "INDEX_SCAN";
+		case PGPA_TAG_JOIN_ORDER:
+			return "JOIN_ORDER";
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case PGPA_TAG_NO_GATHER:
+			return "NO_GATHER";
+		case PGPA_TAG_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+		case PGPA_TAG_SEQ_SCAN:
+			return "SEQ_SCAN";
+		case PGPA_TAG_TID_SCAN:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and reurn an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+	*fail = false;
+
+	switch (tag[0])
+	{
+		case 'b':
+			if (strcmp(tag, "bitmap_heap_scan") == 0)
+				return PGPA_TAG_BITMAP_HEAP_SCAN;
+			break;
+		case 'f':
+			if (strcmp(tag, "foreign_join") == 0)
+				return PGPA_TAG_FOREIGN_JOIN;
+			break;
+		case 'g':
+			if (strcmp(tag, "gather") == 0)
+				return PGPA_TAG_GATHER;
+			if (strcmp(tag, "gather_merge") == 0)
+				return PGPA_TAG_GATHER_MERGE;
+			break;
+		case 'h':
+			if (strcmp(tag, "hash_join") == 0)
+				return PGPA_TAG_HASH_JOIN;
+			break;
+		case 'i':
+			if (strcmp(tag, "index_scan") == 0)
+				return PGPA_TAG_INDEX_SCAN;
+			if (strcmp(tag, "index_only_scan") == 0)
+				return PGPA_TAG_INDEX_ONLY_SCAN;
+			break;
+		case 'j':
+			if (strcmp(tag, "join_order") == 0)
+				return PGPA_TAG_JOIN_ORDER;
+			break;
+		case 'm':
+			if (strcmp(tag, "merge_join_materialize") == 0)
+				return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+			if (strcmp(tag, "merge_join_plain") == 0)
+				return PGPA_TAG_MERGE_JOIN_PLAIN;
+			break;
+		case 'n':
+			if (strcmp(tag, "nested_loop_materialize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+			if (strcmp(tag, "nested_loop_memoize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+			if (strcmp(tag, "nested_loop_plain") == 0)
+				return PGPA_TAG_NESTED_LOOP_PLAIN;
+			if (strcmp(tag, "no_gather") == 0)
+				return PGPA_TAG_NO_GATHER;
+			break;
+		case 'p':
+			if (strcmp(tag, "partitionwise") == 0)
+				return PGPA_TAG_PARTITIONWISE;
+			break;
+		case 's':
+			if (strcmp(tag, "semijoin_non_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+			if (strcmp(tag, "semijoin_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_UNIQUE;
+			if (strcmp(tag, "seq_scan") == 0)
+				return PGPA_TAG_SEQ_SCAN;
+			break;
+		case 't':
+			if (strcmp(tag, "tid_scan") == 0)
+				return PGPA_TAG_TID_SCAN;
+			break;
+	}
+
+	/* didn't work out */
+	*fail = true;
+
+	/* return an arbitrary value to unwind the call stack */
+	return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		bool		first = true;
+		char	   *delims;
+
+		if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+			delims = "{}";
+		else
+			delims = "()";
+
+		appendStringInfoChar(str, delims[0]);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_advice_target(str, child_target);
+		}
+		appendStringInfoChar(str, delims[1]);
+	}
+	else
+	{
+		const char *rt_identifier;
+
+		rt_identifier = pgpa_identifier_string(&target->rid);
+		appendStringInfoString(str, rt_identifier);
+	}
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+	if (itarget->itype != PGPA_INDEX_NAME)
+	{
+		bool		first = true;
+
+		if (itarget->itype == PGPA_INDEX_AND)
+			appendStringInfoString(str, "&&(");
+		else
+			appendStringInfoString(str, "||(");
+
+		foreach_ptr(pgpa_index_target, child_target, itarget->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_index_target(str, child_target);
+		}
+		appendStringInfoChar(str, ')');
+	}
+	else
+	{
+		if (itarget->indnamespace != NULL)
+			appendStringInfo(str, "%s.",
+							 quote_identifier(itarget->indnamespace));
+		appendStringInfoString(str, quote_identifier(itarget->indname));
+	}
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+	if (i1->itype != i2->itype)
+		return false;
+
+	if (i1->itype == PGPA_INDEX_NAME)
+	{
+		/* indnamespace can be NULL, and two NULL values are equal */
+		if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+			(i1->indnamespace == NULL || i2->indnamespace == NULL ||
+			 strcmp(i1->indnamespace, i2->indnamespace) != 0))
+			return false;
+		if (strcmp(i1->indname, i2->indname) != 0)
+			return false;
+	}
+	else
+	{
+		int			i1_length = list_length(i1->children);
+
+		if (i1_length != list_length(i2->children))
+			return false;
+		for (int n = 0; n < i1_length; ++n)
+		{
+			pgpa_index_target *c1 = list_nth(i1->children, n);
+			pgpa_index_target *c2 = list_nth(i2->children, n);
+
+			if (!pgpa_index_targets_equal(c1, c2))
+				return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+	/* For non-identifiers, check all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (pgpa_identifier_matches_target(rid, child_target))
+				return true;
+		}
+		return false;
+	}
+
+	/*
+	 * If a relation identifer mentions a partition name, it should also specify
+	 * a partition schema.
+	 */
+	Assert(rid->partnsp != NULL || rid->partrel == NULL);
+
+	/* Straightforward comparisons of alias name and occcurrence number. */
+	if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+		return false;
+	if (rid->occurrence != target->rid.occurrence)
+		return false;
+
+	/*
+	 * These fields can be NULL on either side, but NULL only matches another
+	 * NULL.
+	 */
+	if (!strings_equal_or_both_null(rid->partnsp, target->rid.partnsp))
+		return false;
+	if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+		return false;
+	if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+		return false;
+
+	return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target)
+{
+	bool		all_rids_used = true;
+	bool		any_rids_used = false;
+	bool		all_targets_used;
+	bool	   *rids_used = palloc0_array(bool, nrids);
+
+	all_targets_used =
+		pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+	for (int i = 0; i < nrids; ++i)
+	{
+		if (rids_used[i])
+			any_rids_used = true;
+		else
+			all_rids_used = false;
+	}
+
+	if (all_rids_used)
+	{
+		if (all_targets_used)
+			return PGPA_ITM_EQUAL;
+		else
+			return PGPA_ITM_KEYS_ARE_SUBSET;
+	}
+	else
+	{
+		if (all_targets_used)
+			return PGPA_ITM_TARGETS_ARE_SUBSET;
+		else if (any_rids_used)
+			return PGPA_ITM_INTERSECTING;
+		else
+			return PGPA_ITM_DISJOINT;
+	}
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target, bool *rids_used)
+{
+	bool		result = false;
+
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		result = true;
+
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+											   rids_used))
+				result = false;
+		}
+	}
+	else
+	{
+		for (int i = 0; i < nrids; ++i)
+		{
+			if (pgpa_identifier_matches_target(&rids[i], target))
+			{
+				rids_used[i] = true;
+				result = true;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
new file mode 100644
index 00000000000..f6fe730a4d4
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -0,0 +1,204 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ *	  abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+	PGPA_TARGET_IDENTIFIER,		/* relation identifier */
+	PGPA_TARGET_ORDERED_LIST,	/* (item ...) */
+	PGPA_TARGET_UNORDERED_LIST	/* {item ...} */
+} pgpa_target_type;
+
+/*
+ * When an advice item describes a bitmap index scan, it may need to describe
+ * the use of multiple indexes.
+ */
+typedef enum
+{
+	PGPA_INDEX_NAME,			/* index schema + name */
+	PGPA_INDEX_AND,				/* &&(item ...) */
+	PGPA_INDEX_OR				/* ||(item ...) */
+} pgpa_index_type;
+
+/*
+ * An index specification. We use this for INDEX_SCAN, INDEX_ONLY_SCAN,
+ * and BITMAP_HEAP_SCAN advice, but in the former two cases, the target must
+ * be of type PGPA_INDEX_NAME.
+ */
+typedef struct pgpa_index_target
+{
+	pgpa_index_type itype;
+
+	/* Index schem and name, when itype == PGPA_INDEX_NAME */
+	char	   *indnamespace;
+	char	   *indname;
+
+	/* List of pgpa_index_target objects, when itype != PGPA_INDEX_NAME */
+	List	   *children;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+	pgpa_target_type ttype;
+
+	/*
+	 * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+	 *
+	 * All identifiers must have an alias name and an occurrence number; the
+	 * remaining fields can be NULL. Note that it's possible to specify a
+	 * partition name without a partition schema, but not the reverse.
+	 */
+	pgpa_identifier rid;
+
+	/*
+	 * This field is set when ttype is PPGA_TARGET_IDENTIFIER and the advice
+	 * tag is PGPA_TAG_INDEX_SCAN, PGPA_TAG_INDEX_ONLY_SCAN, or
+	 * PGPA_TAG_BITMAP_HEAP_SCAN.
+	 */
+	pgpa_index_target *itarget;
+
+	/*
+	 * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+	 * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+	 */
+	List	   *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_FOREIGN_JOIN,
+	PGPA_TAG_GATHER,
+	PGPA_TAG_GATHER_MERGE,
+	PGPA_TAG_HASH_JOIN,
+	PGPA_TAG_INDEX_ONLY_SCAN,
+	PGPA_TAG_INDEX_SCAN,
+	PGPA_TAG_JOIN_ORDER,
+	PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+	PGPA_TAG_MERGE_JOIN_PLAIN,
+	PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+	PGPA_TAG_NESTED_LOOP_MEMOIZE,
+	PGPA_TAG_NESTED_LOOP_PLAIN,
+	PGPA_TAG_NO_GATHER,
+	PGPA_TAG_PARTITIONWISE,
+	PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+	PGPA_TAG_SEMIJOIN_UNIQUE,
+	PGPA_TAG_SEQ_SCAN,
+	PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+	pgpa_advice_tag_type tag;
+	List	   *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by an
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifeirs and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifeirs and targets were
+ * found.
+ */
+typedef enum
+{
+	PGPA_ITM_EQUAL,
+	PGPA_ITM_KEYS_ARE_SUBSET,
+	PGPA_ITM_TARGETS_ARE_SUBSET,
+	PGPA_ITM_INTERSECTING,
+	PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int	pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+					   char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner,
+						 const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int	pgpa_yyparse(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+										   pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+												   pgpa_identifier *rids,
+												   pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+									 pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+									  pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+									 pgpa_index_target *itarget);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_collector.c b/contrib/pg_plan_advice/pgpa_collector.c
new file mode 100644
index 00000000000..7a45b5031fe
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.c
@@ -0,0 +1,637 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.c
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgpa_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgpa_collected_advice;
+
+/*
+ * A bunch of pointers to pgpa_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgpa_local_advice_chunk
+{
+	pgpa_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgpa_local_advice_chunk;
+
+/*
+ * Information about all of the pgpa_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgpa_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgpa_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgpa_local_advice_chunk **chunks;
+} pgpa_local_advice;
+
+/*
+ * Just like pgpa_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgpa_shared_advice_chunk;
+
+/*
+ * Just like pgpa_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgpa_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgpa_local_advice *local_collector = NULL;
+static pgpa_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgpa_collected_advice *pgpa_make_collected_advice(Oid userid,
+														 Oid dbid,
+														 uint64 queryId,
+														 TimestampTz timestamp,
+														 const char *query_string,
+														 const char *advice_string,
+														 dsa_area *area,
+														 dsa_pointer *result);
+static void pgpa_store_local_advice(pgpa_collected_advice *ca);
+static void pgpa_trim_local_advice(int limit);
+static void pgpa_store_shared_advice(dsa_pointer ca_pointer);
+static void pgpa_trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgpa_collected_advice */
+static inline const char *
+query_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgpa_collected_advice */
+static inline const char *
+advice_string(pgpa_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pgpa_collect_advice(uint64 queryId, const char *query_string,
+					const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_plan_advice_local_collection_limit > 0)
+	{
+		pgpa_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+		ca = pgpa_make_collected_advice(userid, dbid, queryId, now,
+										query_string, advice_string,
+										NULL, NULL);
+		pgpa_store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_plan_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_plan_advice_dsa_area();
+		dsa_pointer ca_pointer = InvalidDsaPointer; /* placate compiler */
+
+		pgpa_make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string, area,
+								   &ca_pointer);
+		pgpa_store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgpa_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgpa_collected_advice *
+pgpa_make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+						   TimestampTz timestamp,
+						   const char *query_string,
+						   const char *advice_string,
+						   dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgpa_collected_advice *ca;
+
+	total_length = offsetof(pgpa_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = GetUserId();
+	ca->dbid = MyDatabaseId;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pg_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+pgpa_store_local_advice(pgpa_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgpa_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgpa_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgpa_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgpa_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_local_advice(pg_plan_advice_local_collection_limit);
+}
+
+/*
+ * Add a pg_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_plan_advice DSA area
+ * and should point to an object of type pgpa_collected_advice.
+ */
+static void
+pgpa_store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	pgpa_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgpa_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgpa_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	pgpa_trim_shared_advice(area, pg_plan_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_local_advice(int limit)
+{
+	pgpa_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgpa_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgpa_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_shared_advice(dsa_area *area, int limit)
+{
+	pgpa_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(pgpa_shared_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		pgpa_trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	pgpa_trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgpa_shared_state *state = pg_plan_advice_attach();
+	dsa_area   *area = pg_plan_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgpa_shared_advice *sa = shared_collector;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgpa_shared_advice_chunk *chunk;
+		pgpa_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_plan_advice/pgpa_collector.h b/contrib/pg_plan_advice/pgpa_collector.h
new file mode 100644
index 00000000000..b6e746a06d7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_collector.h
@@ -0,0 +1,18 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.h
+ *	  collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_collector.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_COLLECTOR_H
+#define PGPA_COLLECTOR_H
+
+extern void pgpa_collect_advice(uint64 queryId, const char *query_string,
+								const char *advice_string);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_identifier.c b/contrib/pg_plan_advice/pgpa_identifier.c
new file mode 100644
index 00000000000..a5fa77e083c
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.c
@@ -0,0 +1,476 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ *	  create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted.  Identifier-style escaping is applied to alias_name and
+ * subquery_name.  Whenever we include child_table_name, we always
+ * schema-qualified name, but writing their own plan advice are not required
+ * to do so.  Identifier-style escaping is applied to the schema and to the
+ * relation names separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+									  List *appinfos);
+static int	pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+								   SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+	const char *result;
+
+	Assert(rid->alias_name != NULL);
+	result = quote_identifier(rid->alias_name);
+
+	Assert(rid->occurrence >= 0);
+	if (rid->occurrence > 1)
+		result = psprintf("%s#%d", result, rid->occurrence);
+
+	if (rid->partrel != NULL)
+	{
+		if (rid->partnsp == NULL)
+			result = psprintf("%s/%s", result,
+							  quote_identifier(rid->partrel));
+		else
+			result = psprintf("%s/%s.%s", result,
+							  quote_identifier(rid->partnsp),
+							  quote_identifier(rid->partrel));
+	}
+
+	if (rid->plan_name != NULL)
+		result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+	return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+							   pgpa_identifier *rid)
+{
+	Index		top_rti = rti;
+	int			occurrence = 1;
+	RangeTblEntry *rte;
+	RangeTblEntry *top_rte;
+	char	   *partnsp = NULL;
+	char	   *partrel = NULL;
+
+	/*
+	 * If this is a child RTE, find the topmost parent that is still of type
+	 * RTE_RELATION. We do this because we identify children of partitioned
+	 * tables by the name of the child table, but subqueries can also have
+	 * child rels and we don't care about those here.
+	 */
+	for (;;)
+	{
+		AppendRelInfo *appinfo;
+		RangeTblEntry *parent_rte;
+
+		/* append_rel_array can be NULL if there are no children */
+		if (root->append_rel_array == NULL ||
+			(appinfo = root->append_rel_array[top_rti]) == NULL)
+			break;
+
+		parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+		if (parent_rte->rtekind != RTE_RELATION)
+			break;
+
+		top_rti = appinfo->parent_relid;
+	}
+
+	/* Get the range table entries for the RTI and top RTI. */
+	rte = planner_rt_fetch(rti, root);
+	top_rte = planner_rt_fetch(top_rti, root);
+	Assert(rte->rtekind != RTE_JOIN);
+	Assert(top_rte->rtekind != RTE_JOIN);
+
+	/* Work out the correct occurrence number. */
+	for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+		AppendRelInfo *appinfo;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 *
+		 * NB: append_rel_array can be NULL if there are no children
+		 */
+		if (root->append_rel_array != NULL &&
+			(appinfo = root->append_rel_array[prior_rti]) != NULL)
+		{
+			RangeTblEntry *parent_rte;
+
+			parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+			if (parent_rte->rtekind == RTE_RELATION)
+				continue;
+		}
+
+		/* Skip NULL entries and joins. */
+		prior_rte = planner_rt_fetch(prior_rti, root);
+		if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	/* If this is a child table, get the schema and relation names. */
+	if (rti != top_rti)
+	{
+		partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+		partrel = get_rel_name(rte->relid);
+	}
+
+	/* OK, we have all the answers we need. Return them to the caller. */
+	rid->alias_name = top_rte->eref->aliasname;
+	rid->occurrence = occurrence;
+	rid->partnsp = partnsp;
+	rid->partrel = partrel;
+	rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+								   pgpa_identifier *rids)
+{
+	int			count = 0;
+	int			rti = -1;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = planner_rt_fetch(rti, root);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+		pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+	}
+
+	Assert(count > 0);
+	return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+	Index		rtable_length = list_length(pstmt->rtable);
+	pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+	Index	   *top_rti_map;
+	int			rtinfoindex = 0;
+	SubPlanRTInfo *rtinfo = NULL;
+	SubPlanRTInfo *nextrtinfo = NULL;
+
+	/*
+	 * Account for relations addded by inheritance expansion of partitioned
+	 * tables.
+	 */
+	top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+										  pstmt->appendRelations);
+
+	/*
+	 * When we begin iterating, we're processing the portion of the range
+	 * table that originated from the top-level PlannerInfo, so subrtinfo is
+	 * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+	 * portion of the range table we are processing. nextrtinfo is always the
+	 * SubPlanRTInfo that follows the current one, if any, so when we're
+	 * processing the top-level query's portion of the range table, the next
+	 * SubPlanRTInfo is the very first one.
+	 */
+	if (pstmt->subrtinfos != NULL)
+		nextrtinfo = linitial(pstmt->subrtinfos);
+
+	/* Main loop over the range table. */
+	for (Index rti = 1; rti <= rtable_length; rti++)
+	{
+		const char *plan_name;
+		Index		top_rti;
+		RangeTblEntry *rte;
+		RangeTblEntry *top_rte;
+		char	   *partnsp = NULL;
+		char	   *partrel = NULL;
+		int			occurrence;
+		pgpa_identifier *rid;
+
+		/*
+		 * Advance to the next SubPlanRTInfo, if it's time to do that.
+		 *
+		 * This loop probably shouldn't ever iterate more than once, because
+		 * that would imply that a subquery was planned but added nothing to
+		 * the range table; but let's be defensive and assume it can happen.
+		 */
+		while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+		{
+			rtinfo = nextrtinfo;
+			if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+				nextrtinfo = NULL;
+			else
+				nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+		}
+
+		/* Fetch the range table entry, if any. */
+		rte = rt_fetch(rti, pstmt->rtable);
+
+		/*
+		 * We can't and don't need to identify null entries, and we don't want
+		 * to identify join entries.
+		 */
+		if (rte == NULL || rte->rtekind == RTE_JOIN)
+			continue;
+
+		/*
+		 * If this is not a relation added by partitioned table expansion,
+		 * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+		 * we need the information for the top RTI/RTE, and must also fetch
+		 * the partition schema and name.
+		 */
+		top_rti = top_rti_map[rti - 1];
+		if (rti == top_rti)
+			top_rte = rte;
+		else
+		{
+			top_rte = rt_fetch(top_rti, pstmt->rtable);
+			partnsp =
+				get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+			partrel = get_rel_name(rte->relid);
+		}
+
+		/* Compute the correct occurrence number. */
+		occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+											rtinfo, top_rti);
+
+		/* Get the name of the current plan (NULL for toplevel query). */
+		plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+		/* Save all the details we've derived. */
+		rid = &result[rti - 1];
+		rid->alias_name = top_rte->eref->aliasname;
+		rid->occurrence = occurrence;
+		rid->partnsp = partnsp;
+		rid->partrel = partrel;
+		rid->plan_name = plan_name;
+	}
+
+	return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid)
+{
+	Index		result = 0;
+
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+	{
+		pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+		/* If there's no identifier for this RTI, skip it. */
+		if (rti_rid->alias_name == NULL)
+			continue;
+
+		/*
+		 * If it matches, return this RTI. As usual, an omitted partition
+		 * schema matches anything, but partition and plan names must either
+		 * match exactly or be omitted on both sides.
+		 */
+		if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+			rid->occurrence == rti_rid->occurrence &&
+			(rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+			 strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+			strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+			strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+		{
+			if (result != 0)
+			{
+				/* Multiple matches were found. */
+				return 0;
+			}
+			result = rti;
+		}
+	}
+
+	return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+	Index	   *top_rti_map = palloc0_array(Index, rtable_length);
+
+	/* Initially, make every RTI point to itself. */
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+		top_rti_map[rti - 1] = rti;
+
+	/* Update the map for each AppendRelInfo object. */
+	foreach_node(AppendRelInfo, appinfo, appinfos)
+	{
+		Index		parent_rti = appinfo->parent_relid;
+		RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+		/* If the parent is not RTE_RELATION, ignore this entry. */
+		if (parent_rte->rtekind != RTE_RELATION)
+			continue;
+
+		/*
+		 * Map the child to wherever we mapped the parent. Parents always
+		 * precede their children in the AppendRelInfo list, so this should
+		 * work out.
+		 */
+		top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+	}
+
+	return top_rti_map;
+}
+
+/*
+ * Find the occurence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+					   SubPlanRTInfo *rtinfo, Index rti)
+{
+	Index		rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+	int			occurrence = 1;
+	RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+	for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 */
+		if (top_rti_map[prior_rti - 1] != prior_rti)
+			break;
+
+		/* Skip joins. */
+		prior_rte = rt_fetch(prior_rti, rtable);
+		if (prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	return occurrence;
+}
diff --git a/contrib/pg_plan_advice/pgpa_identifier.h b/contrib/pg_plan_advice/pgpa_identifier.h
new file mode 100644
index 00000000000..b000d2b7081
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ *	  create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+	const char *alias_name;
+	int			occurrence;
+	const char *partnsp;
+	const char *partrel;
+	const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+	if (a == b)
+		return true;
+	else if (a == NULL || b == NULL)
+		return false;
+	else
+		return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+										   pgpa_identifier *rid);
+extern int	pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+											   Bitmapset *relids,
+											   pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+											  pgpa_identifier *rt_identifiers,
+											  pgpa_identifier *rid);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
new file mode 100644
index 00000000000..7d1ab72f11b
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -0,0 +1,629 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	Plan	   *outer_subplan;
+	ElidedNode *outer_elided_node;
+	bool		outer_beneath_any_gather;
+	pgpa_join_strategy *strategy;
+	Plan	  **inner_subplans;
+	ElidedNode **inner_elided_nodes;
+	pgpa_join_unroller **inner_unrollers;
+	bool	   *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+											  Plan *plan,
+											  Plan **realouter,
+											  Plan **realinner,
+											  ElidedNode **elidedrealouter,
+											  ElidedNode **elidedrealinner,
+											  bool *found_any_outer_gather,
+											  bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+										   bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+									ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+	pgpa_join_unroller *join_unroller;
+
+	join_unroller = palloc0_object(pgpa_join_unroller);
+	join_unroller->nallocated = 4;
+	join_unroller->strategy =
+		palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+	join_unroller->inner_subplans =
+		palloc_array(Plan *, join_unroller->nallocated);
+	join_unroller->inner_elided_nodes =
+		palloc_array(ElidedNode *, join_unroller->nallocated);
+	join_unroller->inner_unrollers =
+		palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+	join_unroller->inner_beneath_any_gather =
+		palloc_array(bool, join_unroller->nallocated);
+
+	return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+				 bool beneath_any_gather,
+				 pgpa_join_unroller *join_unroller,
+				 pgpa_join_unroller **outer_join_unroller,
+				 pgpa_join_unroller **inner_join_unroller)
+{
+	pgpa_join_strategy strategy;
+	Plan	   *realinner,
+			   *realouter;
+	ElidedNode *elidedinner,
+			   *elidedouter;
+	int			n;
+	bool		found_any_outer_gather = false;
+	bool		found_any_inner_gather = false;
+
+	Assert(join_unroller != NULL);
+
+	/*
+	 * We need to pass the join_unroller object down through certain types of
+	 * plan nodes -- anything that's considered part of the join strategy, and
+	 * any other nodes that can occur in a join tree despite not being scans
+	 * or joins.
+	 *
+	 * This includes:
+	 *
+	 * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+	 * strategy,
+	 *
+	 * (2) Gather and Gather Merge nodes, which can occur at any point in the
+	 * join tree where the planner decided to initiate parallelism,
+	 *
+	 * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+	 * or GatherMerge,
+	 *
+	 * (4) Agg and Unique nodes, which can occur when we decide to make the
+	 * nullable side of a semijoin unique and then join the result, and
+	 *
+	 * (5) Result nodes with children, which can be added either to project to
+	 * enforce a one-time filter (but Result nodes without children are
+	 * degenerate scans or joins).
+	 */
+	if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+		|| IsA(plan, Gather) || IsA(plan, GatherMerge)
+		|| is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+		|| is_result_node_with_child(plan))
+	{
+		*outer_join_unroller = join_unroller;
+		return;
+	}
+
+	/*
+	 * Since we've already handled nodes that require pass-through treatment,
+	 * this should be an unrollable join.
+	 */
+	strategy = pgpa_decompose_join(walker, plan,
+								   &realouter, &realinner,
+								   &elidedouter, &elidedinner,
+								   &found_any_outer_gather,
+								   &found_any_inner_gather);
+
+	/* If our workspace is full, expand it. */
+	if (join_unroller->nused >= join_unroller->nallocated)
+	{
+		join_unroller->nallocated *= 2;
+		join_unroller->strategy =
+			repalloc_array(join_unroller->strategy,
+						   pgpa_join_strategy,
+						   join_unroller->nallocated);
+		join_unroller->inner_subplans =
+			repalloc_array(join_unroller->inner_subplans,
+						   Plan *,
+						   join_unroller->nallocated);
+		join_unroller->inner_elided_nodes =
+			repalloc_array(join_unroller->inner_elided_nodes,
+						   ElidedNode *,
+						   join_unroller->nallocated);
+		join_unroller->inner_beneath_any_gather =
+			repalloc_array(join_unroller->inner_beneath_any_gather,
+						   bool,
+						   join_unroller->nallocated);
+		join_unroller->inner_unrollers =
+			repalloc_array(join_unroller->inner_unrollers,
+						   pgpa_join_unroller *,
+						   join_unroller->nallocated);
+	}
+
+	/*
+	 * Since we're flattening outer-deep join trees, it follows that if the
+	 * outer side is still an unrollable join, it should be unrolled into this
+	 * same object. Otherwise, we've reached the limit of what we can unroll
+	 * into this object and must remember the outer side as the final outer
+	 * subplan.
+	 */
+	if (elidedouter == NULL && pgpa_is_join(realouter))
+		*outer_join_unroller = join_unroller;
+	else
+	{
+		join_unroller->outer_subplan = realouter;
+		join_unroller->outer_elided_node = elidedouter;
+		join_unroller->outer_beneath_any_gather =
+			beneath_any_gather || found_any_outer_gather;
+	}
+
+	/*
+	 * Store the inner subplan. If it's an unrollable join, it needs to be
+	 * flattened in turn, but into a new unroller object, not this one.
+	 */
+	n = join_unroller->nused++;
+	join_unroller->strategy[n] = strategy;
+	join_unroller->inner_subplans[n] = realinner;
+	join_unroller->inner_elided_nodes[n] = elidedinner;
+	join_unroller->inner_beneath_any_gather[n] =
+		beneath_any_gather || found_any_inner_gather;
+	if (elidedinner == NULL && pgpa_is_join(realinner))
+		*inner_join_unroller = pgpa_create_join_unroller();
+	else
+		*inner_join_unroller = NULL;
+	join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+						 pgpa_join_unroller *join_unroller)
+{
+	pgpa_unrolled_join *ujoin;
+	int			i;
+
+	/*
+	 * We shouldn't have gone even so far as to create a join unroller unless
+	 * we found at least one unrollable join.
+	 */
+	Assert(join_unroller->nused > 0);
+
+	/* Allocate result structures. */
+	ujoin = palloc0_object(pgpa_unrolled_join);
+	ujoin->ninner = join_unroller->nused;
+	ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+	ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+	/* Handle the outermost join. */
+	ujoin->outer.plan = join_unroller->outer_subplan;
+	ujoin->outer.elided_node = join_unroller->outer_elided_node;
+	ujoin->outer.scan =
+		pgpa_build_scan(walker, ujoin->outer.plan,
+						ujoin->outer.elided_node,
+						join_unroller->outer_beneath_any_gather,
+						true);
+
+	/*
+	 * We want the joins from the deepest part of the plan tree to appear
+	 * first in the result object, but the join unroller adds them in exactly
+	 * the reverse of that order, so we need to flip the order of the arrays
+	 * when constructing the final result.
+	 */
+	for (i = 0; i < join_unroller->nused; ++i)
+	{
+		int			k = join_unroller->nused - i - 1;
+
+		/* Copy strategy, Plan, and ElidedNode. */
+		ujoin->strategy[i] = join_unroller->strategy[k];
+		ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+		ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+		/*
+		 * Fill in remaining details, using either the nested join unroller,
+		 * or by deriving them from the plan and elided nodes.
+		 */
+		if (join_unroller->inner_unrollers[k] != NULL)
+			ujoin->inner[i].unrolled_join =
+				pgpa_build_unrolled_join(walker,
+										 join_unroller->inner_unrollers[k]);
+		else
+			ujoin->inner[i].scan =
+				pgpa_build_scan(walker, ujoin->inner[i].plan,
+								ujoin->inner[i].elided_node,
+								join_unroller->inner_beneath_any_gather[i],
+								true);
+	}
+
+	return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+	pfree(join_unroller->strategy);
+	pfree(join_unroller->inner_subplans);
+	pfree(join_unroller->inner_elided_nodes);
+	pfree(join_unroller->inner_unrollers);
+	pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort note on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any correspoding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+					Plan **realouter, Plan **realinner,
+					ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+					bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+	PlannedStmt *pstmt = walker->pstmt;
+	JoinType	jointype = ((Join *) plan)->jointype;
+	Plan	   *outerplan = plan->lefttree;
+	Plan	   *innerplan = plan->righttree;
+	ElidedNode *elidedouter;
+	ElidedNode *elidedinner;
+	pgpa_join_strategy strategy;
+	bool		uniqueouter;
+	bool		uniqueinner;
+
+	elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+	elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+	*found_any_outer_gather = false;
+	*found_any_inner_gather = false;
+
+	switch (nodeTag(plan))
+	{
+		case T_MergeJoin:
+
+			/*
+			 * The planner may have chosen to place a Material node on the
+			 * inner side of the MergeJoin; if this is present, we record it
+			 * as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+			}
+			else
+				strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+			/*
+			 * For a MergeJoin, either the outer or the inner subplan, or
+			 * both, may have needed to be sorted; we must disregard any Sort
+			 * or IncrementalSort node to find the real inner or outer
+			 * subplan.
+			 */
+			if (elidedouter == NULL && is_sorting_plan(outerplan))
+				elidedouter = pgpa_descend_node(pstmt, &outerplan);
+			if (elidedinner == NULL && is_sorting_plan(innerplan))
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			break;
+
+		case T_NestLoop:
+
+			/*
+			 * The planner may have chosen to place a Material or Memoize node
+			 * on the inner side of the NestLoop; if this is present, we
+			 * record it as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+			}
+			else if (elidedinner == NULL && IsA(innerplan, Memoize))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+			}
+			else
+				strategy = JSTRAT_NESTED_LOOP_PLAIN;
+			break;
+
+		case T_HashJoin:
+
+			/*
+			 * The inner subplan of a HashJoin is always a Hash node; the real
+			 * inner subplan is the Hash node's child.
+			 */
+			Assert(IsA(innerplan, Hash));
+			Assert(elidedinner == NULL);
+			elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			strategy = JSTRAT_HASH_JOIN;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+	}
+
+	/*
+	 * The planner may have decided to implement a semijoin by first making
+	 * the nullable side of the plan unique, and then performing a normal join
+	 * against the result. Therefore, we might need to descend through a
+	 * unique node on either side of the plan.
+	 */
+	uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+	uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+	/*
+	 * The planner may have decided to parallelize part of the join tree, so
+	 * we could find a Gather or Gather Merge node here. Note that, if
+	 * present, this will appear below nodes we considered as part of the join
+	 * strategy, but we could find another uniqueness-enforcing node below the
+	 * Gather or Gather Merge, if present.
+	 */
+	if (elidedouter == NULL)
+	{
+		elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+											  found_any_outer_gather);
+		if (found_any_outer_gather &&
+			pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+			uniqueouter = true;
+	}
+	if (elidedinner == NULL)
+	{
+		elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+											  found_any_inner_gather);
+		if (found_any_inner_gather &&
+			pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+			uniqueinner = true;
+	}
+
+	/*
+	 * It's possible that Result node has been inserted either to project a
+	 * target list or to implement a one-time filter. If so, we can descend
+	 * throught it. Note that a result node without a child would be a
+	 * degenerate scan or join, and not something we could descend through.
+	 *
+	 * XXX. I suspect it's possible for this to happen above the Gather or
+	 * Gather Merge node, too, but apparently we have no test case for that
+	 * scenario.
+	 */
+	if (elidedouter == NULL && is_result_node_with_child(outerplan))
+		elidedouter = pgpa_descend_node(pstmt, &outerplan);
+	if (elidedinner == NULL && is_result_node_with_child(innerplan))
+		elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+	/*
+	 * If this is a semijoin that was converted to an inner join by making one
+	 * side or the other unique, make a note that the inner or outer subplan,
+	 * as appropriate, should be treated as a query plan feature when the main
+	 * tree traversal reaches it.
+	 *
+	 * Conversely, if the planner could have made one side of the join unique
+	 * and thereby converted it to an inner join, and chose not to do so, that
+	 * is also worth noting.
+	 *
+	 * NB: This code could appear slightly higher up in in this function, but
+	 * none of the nodes through which we just descended should have
+	 * associated RTIs.
+	 *
+	 * NB: This seems like a somewhat hacky way of passing information up to
+	 * the main tree walk, but I don't currently have a better idea.
+	 */
+	if (uniqueouter)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+	else if (jointype == JOIN_RIGHT_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+	if (uniqueinner)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+	else if (jointype == JOIN_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+	/* Set output parameters. */
+	*realouter = outerplan;
+	*realinner = innerplan;
+	*elidedrealouter = elidedouter;
+	*elidedrealinner = elidedinner;
+	return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+	*plan = (*plan)->lefttree;
+	return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+						bool *found_any_gather)
+{
+	if (IsA(*plan, Gather))
+	{
+		*found_any_gather = true;
+		return pgpa_descend_node(pstmt, plan);
+	}
+
+	if (IsA(*plan, GatherMerge))
+	{
+		ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+		if (elided == NULL && is_sorting_plan(*plan))
+			elided = pgpa_descend_node(pstmt, plan);
+
+		*found_any_gather = true;
+		return elided;
+	}
+
+	return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through a node that we believe is
+ * making one side of a semijoin unique, and otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+						ElidedNode **elided_node)
+{
+	bool		descend = false;
+	bool		sjunique = false;
+
+	if (*elided_node != NULL)
+		return sjunique;
+
+	if (IsA(*plan, Unique))
+	{
+		descend = true;
+		sjunique = true;
+	}
+	else if (IsA(*plan, Agg))
+	{
+		/*
+		 * If this is a simple Agg node, then assume it's here to implement
+		 * semijoin uniqueness. Otherwise, assume it's completing an eager
+		 * aggregation or partitionwise aggregation operation that began at a
+		 * higher level of the plan tree.
+		 *
+		 * XXX. I suspect this logic does not cover all cases: couldn't SJ
+		 * uniqueness be implemented in two steps with an intermediate Gather?
+		 */
+		descend = true;
+		sjunique = (((Agg *) *plan)->aggsplit == AGGSPLIT_SIMPLE);
+	}
+
+	if (descend)
+	{
+		*elided_node = pgpa_descend_node(pstmt, plan);
+
+		if (*elided_node == NULL && is_sorting_plan(*plan))
+			*elided_node = pgpa_descend_node(pstmt, plan);
+	}
+
+	return sjunique;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+	return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+	return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
diff --git a/contrib/pg_plan_advice/pgpa_join.h b/contrib/pg_plan_advice/pgpa_join.h
new file mode 100644
index 00000000000..4dc72986a70
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.h
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+	JSTRAT_MERGE_JOIN_PLAIN = 0,
+	JSTRAT_MERGE_JOIN_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_PLAIN,
+	JSTRAT_NESTED_LOOP_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_MEMOIZE,
+	JSTRAT_HASH_JOIN
+	/* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY		((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+	Plan	   *plan;
+	ElidedNode *elided_node;
+	struct pgpa_scan *scan;
+	pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>.  When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+	/* Outermost member; must not itself be an unrolled join. */
+	pgpa_join_member outer;
+
+	/* Number of inner members. Length of the strategy and inner arrays. */
+	unsigned	ninner;
+
+	/* Array of strategies, one per non-outermost member. */
+	pgpa_join_strategy *strategy;
+
+	/* Array of members, excluding the outermost. Deepest first. */
+	pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+	return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+							 Plan *plan, bool beneath_any_gather,
+							 pgpa_join_unroller *join_unroller,
+							 pgpa_join_unroller **outer_join_unroller,
+							 pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+													pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
new file mode 100644
index 00000000000..89a675ff93e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -0,0 +1,628 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+	const char **rid_strings;
+	StringInfo	buf;
+	int			wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+									  pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+									pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+									  pgpa_scan_strategy strategy,
+									  List *scans);
+static void pgpa_output_bitmap_index_details(pgpa_output_context *context,
+											 Plan *plan);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+									  pgpa_qf_type type,
+									  List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+										char *strategy,
+										List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+								  Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+								  Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+				   pgpa_identifier *rt_identifiers)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	ListCell   *lc;
+	pgpa_output_context context;
+
+	/* Basic initialization. */
+	memset(&context, 0, sizeof(pgpa_output_context));
+	context.buf = buf;
+
+	/*
+	 * Convert identifiers to string form. Note that the loop variable here is
+	 * not an RTI, because RTIs are 1-based. Some RTIs will have no
+	 * identifier, either because the reloptkind is RTE_JOIN or because that
+	 * portion of the query didn't make it into the final plan.
+	 */
+	context.rid_strings = palloc0_array(const char *, rtable_length);
+	for (int i = 0; i < rtable_length; ++i)
+		if (rt_identifiers[i].alias_name != NULL)
+			context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+	/*
+	 * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+	 * from a psql client with default settings, psql will add one space to
+	 * the left of the output and EXPLAIN will add two more to the left of the
+	 * advice. Thus, lines of more than 77 characters will wrap. We set the
+	 * wrap limit to 76 here so that the output won't reach all the way to the
+	 * very last column of the terminal.
+	 *
+	 * Of course, this is fairly arbitrary set of assumptions, and one could
+	 * well make an argument for a different wrap limit, or for a configurable
+	 * one.
+	 */
+	context.wrap_column = 76;
+
+	/*
+	 * Each piece of JOIN_ORDER() advice fully describes the join order for a
+	 * a single unrolled join. Merging is not permitted, because that would
+	 * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+	 * scans should be used for all of those relations, and is thus equivalent
+	 * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+	 * is the driving table which is then joined to "b" then "c" then "d",
+	 * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+	 */
+	foreach(lc, walker->toplevel_unrolled_joins)
+	{
+		pgpa_unrolled_join *ujoin = lfirst(lc);
+
+		if (buf->len > 0)
+			appendStringInfoChar(buf, '\n');
+		appendStringInfo(context.buf, "JOIN_ORDER(");
+		pgpa_output_unrolled_join(&context, ujoin);
+		appendStringInfoChar(context.buf, ')');
+		pgpa_maybe_linebreak(context.buf, context.wrap_column);
+	}
+
+	/* Emit join strategy advice. */
+	for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+	{
+		char	   *strategy = pgpa_cstring_join_strategy(s);
+
+		pgpa_output_simple_strategy(&context,
+									strategy,
+									walker->join_strategies[s]);
+	}
+
+	/*
+	 * Emit scan strategy advice (but not for ordinary scans, which are
+	 * definitionally uninteresting).
+	 */
+	for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+		if (c != PGPA_SCAN_ORDINARY)
+			pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+	/* Emit query feature advice. */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+		pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+	/* Emit NO_GATHER advice. */
+	pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+						  pgpa_unrolled_join *join)
+{
+	pgpa_output_join_member(context, &join->outer);
+
+	for (int k = 0; k < join->ninner; ++k)
+	{
+		pgpa_join_member *member = &join->inner[k];
+
+		pgpa_maybe_linebreak(context->buf, context->wrap_column);
+		appendStringInfoChar(context->buf, ' ');
+		pgpa_output_join_member(context, member);
+	}
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+						pgpa_join_member *member)
+{
+	if (member->unrolled_join != NULL)
+	{
+		appendStringInfoChar(context->buf, '(');
+		pgpa_output_unrolled_join(context, member->unrolled_join);
+		appendStringInfoChar(context->buf, ')');
+	}
+	else
+	{
+		pgpa_scan  *scan = member->scan;
+
+		Assert(scan != NULL);
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '{');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, '}');
+		}
+	}
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+						  pgpa_scan_strategy strategy,
+						  List *scans)
+{
+	bool		first = true;
+
+	if (scans == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_scan_strategy(strategy));
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		Plan	   *plan = scan->plan;
+
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		/* Output the relation identifiers. */
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+
+		/* For scans involving indexes, output index information. */
+		if (strategy == PGPA_SCAN_INDEX)
+		{
+			Assert(IsA(plan, IndexScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_INDEX_ONLY)
+		{
+			Assert(IsA(plan, IndexOnlyScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context,
+									  ((IndexOnlyScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_BITMAP_HEAP)
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_bitmap_index_details(context, plan->lefttree);
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output information about which index or indexes power a BitmapHeapScan.
+ *
+ * We emit &&(i1 i2 i3) for a BitmapAnd between indexes i1, i2, and i3;
+ * and likewise ||(i1 i2 i3) for a similar BitmapOr operation.
+ */
+static void
+pgpa_output_bitmap_index_details(pgpa_output_context *context, Plan *plan)
+{
+	char	   *operator;
+	List	   *bitmapplans;
+	bool		first = true;
+
+	if (IsA(plan, BitmapIndexScan))
+	{
+		BitmapIndexScan *bitmapindexscan = (BitmapIndexScan *) plan;
+
+		pgpa_output_relation_name(context, bitmapindexscan->indexid);
+		return;
+	}
+
+	if (IsA(plan, BitmapOr))
+	{
+		operator = "||";
+		bitmapplans = ((BitmapOr *) plan)->bitmapplans;
+	}
+	else if (IsA(plan, BitmapAnd))
+	{
+		operator = "&&";
+		bitmapplans = ((BitmapAnd *) plan)->bitmapplans;
+	}
+	else
+		elog(ERROR, "unexpected node type: %d", (int) nodeTag(plan));
+
+	appendStringInfo(context->buf, "%s(", operator);
+	foreach_ptr(Plan, child_plan, bitmapplans)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+		pgpa_output_bitmap_index_details(context, child_plan);
+	}
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+	Oid			nspoid = get_rel_namespace(relid);
+	char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+	char	   *relname = get_rel_name(relid);
+
+	appendStringInfoString(context->buf, quote_identifier(relnamespace));
+	appendStringInfoChar(context->buf, '.');
+	appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+						  List *query_features)
+{
+	bool		first = true;
+
+	if (query_features == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_query_feature_type(type));
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(qf->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, qf->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, qf->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+							List *relid_sets)
+{
+	bool		first = true;
+
+	if (relid_sets == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(", strategy);
+
+	foreach_node(Bitmapset, relids, relid_sets)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+	if (relids == NULL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "NO_GATHER(");
+	pgpa_output_relations(context, context->buf, relids);
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+					  Bitmapset *relids)
+{
+	int			rti = -1;
+	bool		first = true;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		const char *rid_string = context->rid_strings[rti - 1];
+
+		if (rid_string == NULL)
+			elog(ERROR, "no identifier for RTI %d", rti);
+
+		if (first)
+		{
+			first = false;
+			appendStringInfoString(buf, rid_string);
+		}
+		else
+		{
+			pgpa_maybe_linebreak(buf, context->wrap_column);
+			appendStringInfo(buf, " %s", rid_string);
+		}
+	}
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+	switch (strategy)
+	{
+		case JSTRAT_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case JSTRAT_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case JSTRAT_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case JSTRAT_HASH_JOIN:
+			return "HASH_JOIN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+	switch (strategy)
+	{
+		case PGPA_SCAN_ORDINARY:
+			return "ORDINARY_SCAN";
+		case PGPA_SCAN_SEQ:
+			return "SEQ_SCAN";
+		case PGPA_SCAN_BITMAP_HEAP:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_SCAN_FOREIGN:
+			return "FOREIGN_JOIN";
+		case PGPA_SCAN_INDEX:
+			return "INDEX_SCAN";
+		case PGPA_SCAN_INDEX_ONLY:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_SCAN_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_SCAN_TID:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+	switch (type)
+	{
+		case PGPAQF_GATHER:
+			return "GATHER";
+		case PGPAQF_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPAQF_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPAQF_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+	}
+
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+	char	   *trailing_nl;
+	int			line_start;
+	int			save_cursor;
+
+	/* If line wrapping is disabled, exit quickly. */
+	if (wrap_column <= 0)
+		return;
+
+	/*
+	 * Set line_start to the byte offset within buf->data of the first
+	 * character of the current line, where the current line means the last
+	 * one in the buffer. Note that line_start could be the offset of the
+	 * trailing '\0' if the last character in the buffer is a line break.
+	 */
+	trailing_nl = strrchr(buf->data, '\n');
+	if (trailing_nl == NULL)
+		line_start = 0;
+	else
+		line_start = (trailing_nl - buf->data) + 1;
+
+	/*
+	 * Remember that the current end of the buffer is a potential location to
+	 * insert a line break on a future call to this function.
+	 */
+	save_cursor = buf->cursor;
+	buf->cursor = buf->len;
+
+	/* If we haven't passed the wrap column, we don't need a newline. */
+	if (buf->len - line_start <= wrap_column)
+		return;
+
+	/*
+	 * It only makes sense to insert a newline at a position later than the
+	 * beginning of the current line.
+	 */
+	if (buf->cursor <= line_start)
+		return;
+
+	/* Insert a newline at the previous cursor location. */
+	enlargeStringInfo(buf, 1);
+	memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+			buf->len - save_cursor);
+	++buf->cursor;
+	buf->data[++buf->len] = '\0';
+	buf->data[save_cursor] = '\n';
+}
diff --git a/contrib/pg_plan_advice/pgpa_output.h b/contrib/pg_plan_advice/pgpa_output.h
new file mode 100644
index 00000000000..47496d76f52
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+							   pgpa_plan_walker_context *walker,
+							   pgpa_identifier *rt_identifiers);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_parser.y b/contrib/pg_plan_advice/pgpa_parser.y
new file mode 100644
index 00000000000..4617e7f2f64
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_parser.y
@@ -0,0 +1,337 @@
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc.  This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE   pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+	char	   *str;
+	int			integer;
+	List	   *list;
+	pgpa_advice_item *item;
+	pgpa_advice_target *target;
+	pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_BITMAP TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+%token TOK_OR TOK_AND
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list bitmap_sublist bitmap_target_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> bitmap_target_item index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+		{
+			(void) yynerrs;				/* suppress compiler warning */
+			*result = $1;
+		}
+	;
+
+advice_item_list: advice_item_list advice_item
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_JOIN_ORDER;
+			$$->targets = $3;
+		}
+	| TOK_TAG_INDEX '(' index_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "index_only_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+			else if (strcmp($1, "index_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_BITMAP '(' bitmap_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+			$$->targets = $3;
+		}
+	| TOK_TAG_SIMPLE '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "no_gather") == 0)
+				$$->tag = PGPA_TAG_NO_GATHER;
+			else if (strcmp($1, "seq_scan") == 0)
+				$$->tag = PGPA_TAG_SEQ_SCAN;
+			else if (strcmp($1, "tid_scan") == 0)
+				$$->tag = PGPA_TAG_TID_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_GENERIC '(' generic_target_list ')'
+		{
+			bool	fail;
+
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = pgpa_parse_advice_tag($1, &fail);
+			if (fail)
+			{
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "unrecognized advice tag");
+			}
+
+			if ($$->tag == PGPA_TAG_FOREIGN_JOIN)
+			{
+				foreach_ptr(pgpa_advice_target, target, $3)
+				{
+					if (target->ttype == PGPA_TARGET_IDENTIFIER ||
+						list_length(target->children) == 1)
+							pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+										 "FOREIGN_JOIN targets must contain more than one relation identifier");
+				}
+			}
+
+			$$->targets = $3;
+		}
+	;
+
+relation_identifier: identifier opt_ri_occurrence opt_partition opt_plan_name
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_IDENTIFIER;
+			$$->rid.alias_name = $1;
+			$$->rid.occurrence = $2;
+			if (list_length($3) == 2)
+			{
+				$$->rid.partnsp = linitial($3);
+				$$->rid.partrel = lsecond($3);
+			}
+			else if ($3 != NIL)
+				$$->rid.partrel = linitial($3);
+			$$->rid.plan_name = $4;
+		}
+	;
+
+index_name: identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indname = $1;
+		}
+	| identifier '.' identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_NAME;
+			$$->indnamespace = $1;
+			$$->indname = $3;
+		}
+	;
+
+opt_ri_occurrence:
+	'#' TOK_INTEGER
+		{
+			if ($2 <= 0)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "only positive occurrence numbers are permitted");
+			$$ = $2;
+		}
+	|
+		{
+			/* The default occurrence number is 1. */
+			$$ = 1;
+		}
+	;
+
+identifier: TOK_IDENT
+	| TOK_TAG_JOIN_ORDER
+	| TOK_TAG_INDEX
+	| TOK_TAG_BITMAP
+	| TOK_TAG_SIMPLE
+	| TOK_TAG_GENERIC
+	;
+
+/*
+ * When generating advice, we always schema-qualify the partition name, but
+ * when parsing advice, we accept a specification that lacks one.
+ */
+opt_partition:
+	'/' TOK_IDENT '.' TOK_IDENT
+		{ $$ = list_make2($2, $4); }
+	| '/' TOK_IDENT
+		{ $$ = list_make1($2); }
+	|
+		{ $$ = NIL; }
+	;
+
+opt_plan_name:
+	'@' TOK_IDENT
+		{ $$ = $2; }
+	|
+		{ $$ = NULL; }
+	;
+
+bitmap_target_list: bitmap_target_list relation_identifier bitmap_target_item
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+bitmap_target_item: index_name
+		{ $$ = $1; }
+	| TOK_OR '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_OR;
+			$$->children = $3;
+		}
+	| TOK_AND '(' bitmap_sublist ')'
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->itype = PGPA_INDEX_AND;
+			$$->children = $3;
+		}
+	;
+
+bitmap_sublist: bitmap_sublist bitmap_target_item
+		{ $$ = lappend($1, $2); }
+	| bitmap_target_item
+		{ $$ = list_make1($1); }
+	;
+
+generic_target_list: generic_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| generic_target_list generic_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+generic_sublist: '(' generic_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+index_target_list:
+	  index_target_list relation_identifier index_name
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_target_list: join_order_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| join_order_target_list join_order_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_sublist:
+	'(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	| '{' simple_target_list '}'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_UNORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+simple_target_list: simple_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+%%
+
+/*
+ * Parse an advice_string and return the resulting list of pgpa_advice_item
+ * objects. If a parse error occurs, instead return NULL.
+ *
+ * If the return value is NULL, *error_p will be set to the error message;
+ * otherwise, *error_p will be set to NULL.
+ */
+List *
+pgpa_parse(const char *advice_string, char **error_p)
+{
+	yyscan_t	scanner;
+	List	   *result;
+	char	   *error = NULL;
+
+	pgpa_scanner_init(advice_string, &scanner);
+	pgpa_yyparse(&result, &error, scanner);
+	pgpa_scanner_finish(scanner);
+
+	if (error != NULL)
+	{
+		*error_p = error;
+		return NULL;
+	}
+
+	*error_p = NULL;
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
new file mode 100644
index 00000000000..5c82f266b18
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -0,0 +1,1764 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.c
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "common/hashfn_unstable.h"
+#include "nodes/makefuncs.h"
+#include "optimizer/extendplan.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/plancat.h"
+#include "optimizer/planner.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * When assertions are enabled, we try generating relation identifiers during
+ * planning, saving them in a hash table, and then cross-checking them against
+ * the ones generated after planning is complete.
+ */
+typedef struct pgpa_ri_checker_key
+{
+	char	   *plan_name;
+	Index		rti;
+} pgpa_ri_checker_key;
+
+typedef struct pgpa_ri_checker
+{
+	pgpa_ri_checker_key key;
+	uint32		status;
+	const char *rid_string;
+} pgpa_ri_checker;
+
+static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
+
+static inline bool
+pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
+{
+	if (a.rti != b.rti)
+		return false;
+	if (a.plan_name == NULL)
+		return (b.plan_name == NULL);
+	if (b.plan_name == NULL)
+		return false;
+	return strcmp(a.plan_name, b.plan_name) == 0;
+}
+
+#define SH_PREFIX			pgpa_ri_check
+#define SH_ELEMENT_TYPE		pgpa_ri_checker
+#define SH_KEY_TYPE			pgpa_ri_checker_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#endif
+
+typedef struct pgpa_planner_state
+{
+	ExplainState *explain_state;
+	bool		generate_advice_string;
+	pgpa_trove *trove;
+	MemoryContext trove_cxt;
+	List	   *sj_unique_rels;
+
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_check_hash *ri_check_hash;
+#endif
+} pgpa_planner_state;
+
+typedef struct pgpa_join_state
+{
+	/* Most-recently-considered outer rel. */
+	RelOptInfo *outerrel;
+
+	/* Most-recently-considered inner rel. */
+	RelOptInfo *innerrel;
+
+	/*
+	 * Array of relation identifiers for all members of this joinrel, with
+	 * outerrel idenifiers before innerrel identifiers.
+	 */
+	pgpa_identifier *rids;
+
+	/* Number of outer rel identifiers. */
+	int			outer_count;
+
+	/* Number of inner rel identifiers. */
+	int			inner_count;
+
+	/*
+	 * Trove lookup results.
+	 *
+	 * join_entries and rel_entries are arrays of entries, and join_indexes
+	 * and rel_indexes are the integer offsets within those arrays of entries
+	 * potentially relevant to us. The "join" fields correspond to a lookup
+	 * using PGPA_TROVE_LOOKUP_JOIN and the "rel" fields to a lookup using
+	 * PGPA_TROVE_LOOKUP_REL.
+	 */
+	pgpa_trove_entry *join_entries;
+	Bitmapset  *join_indexes;
+	pgpa_trove_entry *rel_entries;
+	Bitmapset  *rel_indexes;
+} pgpa_join_state;
+
+/* Saved hook values */
+static get_relation_info_hook_type prev_get_relation_info = NULL;
+static join_path_setup_hook_type prev_join_path_setup = NULL;
+static joinrel_setup_hook_type prev_joinrel_setup = NULL;
+static planner_setup_hook_type prev_planner_setup = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+
+/* Other global variabes */
+static int	planner_extension_id = -1;
+
+/* Function prototypes. */
+static void pgpa_get_relation_info(PlannerInfo *root,
+								   Oid relationObjectId,
+								   bool inhparent,
+								   RelOptInfo *rel);
+static void pgpa_joinrel_setup(PlannerInfo *root,
+							   RelOptInfo *joinrel,
+							   RelOptInfo *outerrel,
+							   RelOptInfo *innerrel,
+							   SpecialJoinInfo *sjinfo,
+							   List *restrictlist);
+static void pgpa_join_path_setup(PlannerInfo *root,
+								 RelOptInfo *joinrel,
+								 RelOptInfo *outerrel,
+								 RelOptInfo *innerrel,
+								 JoinType jointype,
+								 JoinPathExtraData *extra);
+static void pgpa_planner_setup(PlannerGlobal *glob, Query *parse,
+							   const char *query_string,
+							   double *tuple_fraction,
+							   ExplainState *es);
+static void pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string, PlannedStmt *pstmt);
+static void pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p,
+											  char *plan_name,
+											  pgpa_join_state *pjs);
+static void pgpa_planner_apply_join_path_advice(JoinType jointype,
+												uint64 *pgs_mask_p,
+												char *plan_name,
+												pgpa_join_state *pjs);
+static void pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+										   pgpa_trove_entry *scan_entries,
+										   Bitmapset *scan_indexes,
+										   pgpa_trove_entry *rel_entries,
+										   Bitmapset *rel_indexes);
+static uint64 pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag);
+static bool pgpa_join_order_permits_join(int outer_count, int inner_count,
+										 pgpa_identifier *rids,
+										 pgpa_trove_entry *entry);
+static bool pgpa_join_method_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+
+static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+										  pgpa_trove_lookup_type type,
+										  pgpa_identifier *rt_identifiers,
+										  pgpa_plan_walker_context *walker);
+
+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);
+
+/*
+ * Install planner-related hooks.
+ */
+void
+pgpa_planner_install_hooks(void)
+{
+	planner_extension_id = GetPlannerExtensionId("pg_plan_advice");
+	prev_get_relation_info = get_relation_info_hook;
+	get_relation_info_hook = pgpa_get_relation_info;
+	prev_joinrel_setup = joinrel_setup_hook;
+	joinrel_setup_hook = pgpa_joinrel_setup;
+	prev_join_path_setup = join_path_setup_hook;
+	join_path_setup_hook = pgpa_join_path_setup;
+	prev_planner_setup = planner_setup_hook;
+	planner_setup_hook = pgpa_planner_setup;
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgpa_planner_shutdown;
+}
+
+/*
+ * Hook function for get_relation_info().
+ *
+ * We can apply scan advice at this opint, and we also usee this as an
+ * opportunity to do range-table identifier cross-checking in assert-enabled
+ * builds.
+ */
+static void
+pgpa_get_relation_info(PlannerInfo *root, Oid relationObjectId,
+					   bool inhparent, RelOptInfo *rel)
+{
+	pgpa_planner_state *pps;
+
+	/* 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. */
+	if (pps != NULL)
+		pgpa_ri_checker_save(pps, root, rel);
+
+	/* If query advice was provided, search for relevant entries. */
+	if (pps != NULL && pps->trove != NULL)
+	{
+		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,
+						  &tresult_scan);
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+						  &tresult_rel);
+
+		/* If relevant entries were found, apply them. */
+		if (tresult_scan.indexes != NULL || tresult_rel.indexes != NULL)
+			pgpa_planner_apply_scan_advice(rel,
+										   tresult_scan.entries,
+										   tresult_scan.indexes,
+										   tresult_rel.entries,
+										   tresult_rel.indexes);
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_get_relation_info)
+		(*prev_get_relation_info) (root, relationObjectId, inhparent, rel);
+}
+
+/*
+ * Search for advice pertaining to a proposed join.
+ */
+static pgpa_join_state *
+pgpa_get_join_state(PlannerInfo *root, RelOptInfo *joinrel,
+					RelOptInfo *outerrel, RelOptInfo *innerrel)
+{
+	pgpa_planner_state *pps;
+	pgpa_join_state *pjs;
+	bool		new_pjs = false;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+	if (pps == NULL || pps->trove == NULL)
+	{
+		/* No advice applies to this query, hence none to this joinrel. */
+		return NULL;
+	}
+
+	/*
+	 * See whether we've previously associated a pgpa_join_state with this
+	 * joinrel. If we have not, we need to try to construct one. If we have,
+	 * then there are two cases: (a) if innerrel and outerrel are unchanged,
+	 * we can simply use it, and (b) if they have changed, we need to rejigger
+	 * the array of identifiers but can still skip the trove lookup.
+	 */
+	pjs = GetRelOptInfoExtensionState(joinrel, planner_extension_id);
+	if (pjs != NULL)
+	{
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+		{
+			/*
+			 * If there's no potentially relevant advice, then the presence of
+			 * this pgpa_join_state acts like a negative cache entry: it tells
+			 * us not to bother searching the trove for advice, because we
+			 * will not find any.
+			 */
+			return NULL;
+		}
+
+		if (pjs->outerrel == outerrel && pjs->innerrel == innerrel)
+		{
+			/* No updates required, so just return. */
+			/* XXX. Does this need to do something different under GEQO? */
+			return pjs;
+		}
+	}
+
+	/*
+	 * If there's no pgpa_join_state yet, we need to allocate one. Trove keys
+	 * will not get built for RTE_JOIN RTEs, so the array may end up being
+	 * larger than needed. It's not worth trying to compute a perfectly
+	 * accurate count here.
+	 */
+	if (pjs == NULL)
+	{
+		int			pessimistic_count = bms_num_members(joinrel->relids);
+
+		pjs = palloc0_object(pgpa_join_state);
+		pjs->rids = palloc_array(pgpa_identifier, pessimistic_count);
+		new_pjs = true;
+	}
+
+	/*
+	 * Either we just allocated a new pgpa_join_state, or the existing one
+	 * needs reconfiguring for a new innerrel and outerrel. The required array
+	 * size can't change, so we can overwrite the existing one.
+	 */
+	pjs->outerrel = outerrel;
+	pjs->innerrel = innerrel;
+	pjs->outer_count =
+		pgpa_compute_identifiers_by_relids(root, outerrel->relids, pjs->rids);
+	pjs->inner_count =
+		pgpa_compute_identifiers_by_relids(root, innerrel->relids,
+										   pjs->rids + pjs->outer_count);
+
+	/*
+	 * If we allocated a new pgpa_join_state, search our trove of advice for
+	 * relevant entries. The trove lookup will return the same results for
+	 * every outerrel/innerrel combination, so we don't need to repeat that
+	 * work every time.
+	 */
+	if (new_pjs)
+	{
+		pgpa_trove_result tresult;
+
+		/* Find join entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_JOIN,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->join_entries = tresult.entries;
+		pjs->join_indexes = tresult.indexes;
+
+		/* Find rel entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->rel_entries = tresult.entries;
+		pjs->rel_indexes = tresult.indexes;
+
+		/* Now that the new pgpa_join_state is fully valid, save a pointer. */
+		SetRelOptInfoExtensionState(joinrel, planner_extension_id, pjs);
+
+		/*
+		 * If there was no relevant advice found, just return NULL. This
+		 * pgpa_join_state will stick around as a sort of negative cache
+		 * entry, so that future calls for this same joinrel quickly return
+		 * NULL.
+		 */
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+			return NULL;
+	}
+
+	return pjs;
+}
+
+/*
+ * Enforce any provided advice that is relevant to any method of implementing
+ * this join.
+ *
+ * Although we're passed the outerrel and innerrel here, those are just
+ * whatever values happened to prompt the creation of this joinrel; they
+ * shouldn't really influence our choice of what advice to apply.
+ */
+static void
+pgpa_joinrel_setup(PlannerInfo *root, RelOptInfo *joinrel,
+				   RelOptInfo *outerrel, RelOptInfo *innerrel,
+				   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_joinrel_advice(&joinrel->pgs_mask,
+										  root->plan_name,
+										  pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_joinrel_setup)
+		(*prev_joinrel_setup) (root, joinrel, outerrel, innerrel,
+							   sjinfo, restrictlist);
+}
+
+/*
+ * Enforce any provided advice that is relevant to this particular method of
+ * implementing this particular join.
+ */
+static void
+pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
+					 RelOptInfo *outerrel, RelOptInfo *innerrel,
+					 JoinType jointype, JoinPathExtraData *extra)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/*
+	 * If we're considering implementing a semijoin by making one side unique,
+	 * make a note of it in the pgpa_planner_state. See comments for
+	 * pgpa_sj_unique_rel for why we do this.
+	 */
+	if (jointype == JOIN_UNIQUE_OUTER || jointype == JOIN_UNIQUE_INNER)
+	{
+		pgpa_planner_state *pps;
+		RelOptInfo *uniquerel;
+
+		uniquerel = jointype == JOIN_UNIQUE_OUTER ? outerrel : innerrel;
+		pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+		if (pps->generate_advice_string)
+		{
+			bool		found = false;
+
+			/* Avoid adding duplicates. */
+			foreach_ptr(pgpa_sj_unique_rel, ur, pps->sj_unique_rels)
+			{
+				/*
+				 * We should always use the same pointer for the same plan
+				 * name, so we need not use strcmp() here.
+				 */
+				if (root->plan_name == ur->plan_name &&
+					bms_equal(uniquerel->relids, ur->relids))
+				{
+					found = true;
+					break;
+				}
+			}
+
+			/* If not a duplicate, append to the list. */
+			if (!found)
+			{
+				pgpa_sj_unique_rel *ur = palloc_object(pgpa_sj_unique_rel);
+
+				ur->plan_name = root->plan_name;
+				ur->relids = uniquerel->relids;
+				pps->sj_unique_rels = lappend(pps->sj_unique_rels, ur);
+			}
+		}
+	}
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+		pgpa_planner_apply_join_path_advice(jointype,
+											&extra->pgs_mask,
+											root->plan_name,
+											pjs);
+
+	/* Pass call to previous hook. */
+	if (prev_join_path_setup)
+		(*prev_join_path_setup) (root, joinrel, outerrel, innerrel,
+								 jointype, extra);
+}
+
+/*
+ * Carry out whatever setup work we need to do before planning.
+ */
+static void
+pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
+				   double *tuple_fraction, ExplainState *es)
+{
+	pgpa_trove *trove = NULL;
+	pgpa_planner_state *pps;
+	bool		generate_advice_string = false;
+	bool		needs_pps = false;
+
+	/*
+	 * Decide whether we need to generate an advice string. We must do this if
+	 * at least one collector is enabled or if the user has requested it using
+	 * the EXPLAIN (PLAN_ADVICE) option.
+	 */
+	generate_advice_string = (pg_plan_advice_local_collection_limit > 0 ||
+							  pg_plan_advice_shared_collection_limit > 0 ||
+							  pg_plan_advice_should_explain(es));
+	if (generate_advice_string)
+		needs_pps = true;
+
+	/*
+	 * If any advice was provided, build a trove of advice for use during
+	 * planning.
+	 */
+	if (pg_plan_advice_advice != NULL && pg_plan_advice_advice[0] != '\0')
+	{
+		List	   *advice_items;
+		char	   *error;
+
+		/*
+		 * Parsing shouldn't fail here, because we must have previously parsed
+		 * successfully in pg_plan_advice_advice_check_hook, but if it does,
+		 * emit a warning.
+		 */
+		advice_items = pgpa_parse(pg_plan_advice_advice, &error);
+		if (error)
+			elog(WARNING, "could not parse advice: %s", error);
+
+		/*
+		 * It's possible that the advice string was non-empty but contained no
+		 * actual advice, e.g. it was all whitespace.
+		 */
+		if (advice_items != NIL)
+		{
+			trove = pgpa_build_trove(advice_items);
+			needs_pps = true;
+		}
+	}
+
+#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.
+	 */
+	if (needs_pps)
+	{
+		pps = palloc0_object(pgpa_planner_state);
+		pps->explain_state = es;
+		pps->generate_advice_string = generate_advice_string;
+		pps->trove = trove;
+#ifdef USE_ASSERT_CHECKING
+		pps->ri_check_hash =
+			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
+#endif
+		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
+	}
+}
+
+/*
+ * Carry out whatever work we want to do after planning is complete.
+ */
+static void
+pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	pgpa_planner_state *pps;
+	pgpa_trove *trove = NULL;
+	ExplainState *es = NULL;
+	pgpa_plan_walker_context walker = {0};	/* placate compiler */
+	bool		do_advice_feedback;
+	bool		generate_advice_string = false;
+	List	   *pgpa_items = NIL;
+	pgpa_identifier *rt_identifiers = NULL;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
+	if (pps != NULL)
+	{
+		trove = pps->trove;
+		es = pps->explain_state;
+		generate_advice_string = pps->generate_advice_string;
+	}
+
+	/*
+	 * If any advice was specified, and if we're running under EXPLAIN, then
+	 * we should try try to provide advice feedback.
+	 *
+	 * If we're trying to generate an advice string or if we're trying to
+	 * provide advice feedback, then we will need to create range table
+	 * identifiers.
+	 */
+	do_advice_feedback = (trove != NULL && es != NULL);
+	if (generate_advice_string || do_advice_feedback)
+	{
+		pgpa_plan_walker(&walker, pstmt, pps->sj_unique_rels);
+		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+	}
+
+	/* Generate the advice string, if we need to do so. */
+	if (generate_advice_string)
+	{
+		char	   *advice_string;
+		StringInfoData buf;
+
+		/* Generate a textual advice string. */
+		initStringInfo(&buf);
+		pgpa_output_advice(&buf, &walker, rt_identifiers);
+		advice_string = buf.data;
+
+		/* If the advice string is empty, don't bother collecting it. */
+		if (advice_string[0] != '\0')
+			pgpa_collect_advice(pstmt->queryId, query_string, advice_string);
+
+		/*
+		 * If we've gone to the trouble of generating an advice string, and if
+		 * we're inside EXPLAIN, save the string so we don't need to
+		 * regenerate it.
+		 */
+		if (es != NULL)
+			pgpa_items = lappend(pgpa_items,
+								 makeDefElem("advice_string",
+											 (Node *) makeString(advice_string),
+											 -1));
+	}
+
+	/*
+	 * If we are planning within EXPLAIN, make arrangements to allow EXPLAIN
+	 * to tell the user what has happened with the provided advice.
+	 *
+	 * NB: If EXPLAIN is used on a prepared is a prepared statement, planning
+	 * will have already happened happened without recording these details. We
+	 * could consider adding a GUC to cater to that scenario; or we could do
+	 * this work all the time, but that seems like too much overhead.
+	 */
+	if (do_advice_feedback)
+	{
+		List	   *feedback = NIL;
+
+		/*
+		 * Inject a Node-tree representation of all the trove-entry flags into
+		 * the PlannedStmt.
+		 */
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_SCAN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_JOIN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_REL,
+												rt_identifiers, &walker);
+
+		pgpa_items = lappend(pgpa_items, makeDefElem("feedback",
+													 (Node *) feedback,
+													 -1));
+	}
+
+	/* Push whatever data we're saving into the PlannedStmt. */
+	if (pgpa_items != NIL)
+		pstmt->extension_state =
+			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);
+}
+
+/*
+ * Enforce overall restrictions on a join relation that apply uniformly
+ * regardless of the choice of inner and outer rel.
+ */
+static void
+pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
+								  pgpa_join_state *pjs)
+{
+	int			i = -1;
+	int			flags;
+	bool		gather_conflict = false;
+	uint64		gather_mask = 0;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	bool		partitionwise_conflict = false;
+	int			partitionwise_outcome = 0;
+	Bitmapset  *partitionwise_partial_match = NULL;
+	Bitmapset  *partitionwise_full_match = NULL;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type itm;
+		bool		full_match = false;
+		uint64		my_gather_mask = 0;
+		int			my_partitionwise_outcome = 0;	/* >0 yes, <0 no */
+
+		/*
+		 * For GATHER and GATHER_MERGE, if the specified relations exactly
+		 * match this joinrel, do whatever the advice says; otherwise, don't
+		 * allow Gather or Gather Merge at this level. For NO_GATHER, there
+		 * must be a single target relation which must be included in this
+		 * joinrel, so just don't allow Gather or Gather Merge here, full
+		 * stop.
+		 */
+		if (entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			full_match = true;
+		}
+		else
+		{
+			int			total_count;
+
+			total_count = pjs->outer_count + pjs->inner_count;
+			itm = pgpa_identifiers_match_target(total_count, pjs->rids,
+												entry->target);
+			Assert(itm != PGPA_ITM_DISJOINT);
+
+			if (itm == PGPA_ITM_EQUAL)
+			{
+				full_match = true;
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+					my_partitionwise_outcome = 1;
+				else if (entry->tag == PGPA_TAG_GATHER)
+					my_gather_mask = PGS_GATHER;
+				else if (entry->tag == PGPA_TAG_GATHER_MERGE)
+					my_gather_mask = PGS_GATHER_MERGE;
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+			else
+			{
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else if (entry->tag == PGPA_TAG_GATHER ||
+						 entry->tag == PGPA_TAG_GATHER_MERGE)
+				{
+					my_partitionwise_outcome = -1;
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (full_match)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+
+		/*
+		 * Likewise, if we set my_partitionwise_outcome up above, then we (1)
+		 * make a note if the advice conflicted, (2) remember what the desired
+		 * outcome was, and (3) remember whether this was a full or partial
+		 * match.
+		 */
+		if (my_partitionwise_outcome != 0)
+		{
+			if (partitionwise_outcome != 0 &&
+				partitionwise_outcome != my_partitionwise_outcome)
+				partitionwise_conflict = true;
+			partitionwise_outcome = my_partitionwise_outcome;
+			if (full_match)
+				partitionwise_full_match =
+					bms_add_member(partitionwise_full_match, i);
+			else
+				partitionwise_partial_match =
+					bms_add_member(partitionwise_partial_match, i);
+		}
+	}
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched, and if
+	 * the set of targets exactly matched this relation, fully matched. If
+	 * there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
+
+	/* Likewise for partitionwise advice. */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (partitionwise_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		*pgs_mask_p &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		*pgs_mask_p |= gather_mask;
+	}
+
+	/*
+	 * If there is a non-conflicting partitionwise specification, enforce.
+	 *
+	 * To force a partitionwise join, we disable all the ordinary means of
+	 * performing a join, and instead only Append and MergeAppend paths here.
+	 * To prevent one, we just disable Append and MergeAppend.  Note that we
+	 * must not unset PGS_CONSIDER_PARTITIONWISE even when we don't want a
+	 * partitionwise join here, because we might want one at a higher level
+	 * that is constructing using paths from this level.
+	 */
+	if (partitionwise_outcome != 0 && !partitionwise_conflict)
+	{
+		if (partitionwise_outcome > 0)
+			*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) |
+				PGS_APPEND | PGS_MERGE_APPEND | PGS_CONSIDER_PARTITIONWISE;
+		else
+			*pgs_mask_p &= ~(PGS_APPEND | PGS_MERGE_APPEND);
+	}
+}
+
+/*
+ * Enforce restrictions on the join order or join method.
+ *
+ * Note that, although it is possible to view PARTITIONWISE advice as
+ * controlling the join method, we can't enforce it here, because the code
+ * path where this executes only deals with join paths that are built directly
+ * from a single outer path and a single inner path.
+ */
+static void
+pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
+									char *plan_name,
+									pgpa_join_state *pjs)
+{
+	int			i = -1;
+	Bitmapset  *jo_permit_indexes = NULL;
+	Bitmapset  *jo_deny_indexes = NULL;
+	Bitmapset  *jm_indexes = NULL;
+	bool		jm_conflict = false;
+	uint32		join_mask = 0;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->join_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->join_entries[i];
+		uint32		my_join_mask;
+
+		/* Handle join order advice. */
+		if (entry->tag == PGPA_TAG_JOIN_ORDER)
+		{
+			if (pgpa_join_order_permits_join(pjs->outer_count,
+											 pjs->inner_count,
+											 pjs->rids,
+											 entry))
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			else
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			continue;
+		}
+
+		/* Handle join strategy advice. */
+		my_join_mask = pgpa_join_strategy_mask_from_advice_tag(entry->tag);
+		if (my_join_mask != 0)
+		{
+			bool		permit;
+			bool		restrict_method;
+
+			if (entry->tag == PGPA_TAG_FOREIGN_JOIN)
+				permit = pgpa_opaque_join_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			else
+				permit = pgpa_join_method_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			if (!permit)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				jm_indexes = bms_add_member(jo_permit_indexes, i);
+				if (join_mask != 0 && join_mask != my_join_mask)
+					jm_conflict = true;
+				join_mask = my_join_mask;
+			}
+			continue;
+		}
+
+		/* Handle semijoin uniqueness advice. */
+		if (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE ||
+			entry->tag == PGPA_TAG_SEMIJOIN_NON_UNIQUE)
+		{
+			bool		advice_unique;
+			bool		jt_unique;
+			bool		jt_non_unique;
+			bool		restrict_method;
+
+			/* Advice wants to unique-ify and use a regular join? */
+			advice_unique = (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE);
+
+			/* Planner is trying to unique-ify and use a regular join? */
+			jt_unique = (jointype == JOIN_UNIQUE_INNER ||
+						 jointype == JOIN_UNIQUE_OUTER);
+
+			/* Planner is trying a semi-join, without unique-ifying? */
+			jt_non_unique = (jointype == JOIN_SEMI ||
+							 jointype == JOIN_RIGHT_SEMI);
+
+			/*
+			 * These advice tags behave very much like join method advice, in
+			 * that they want the inner side of the semijoin to match the
+			 * relations listed in the advice. Hence, we test whether join
+			 * method advice would enforce a join order restriction here, and
+			 * disallow the join if not.
+			 *
+			 * XXX. Think harder about right semijoins.
+			 */
+			if (!pgpa_join_method_permits_join(pjs->outer_count,
+											   pjs->inner_count,
+											   pjs->rids,
+											   entry,
+											   &restrict_method))
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				if (!jt_unique && !jt_non_unique)
+				{
+					/*
+					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
+					 * or SJ_NON_UNIQUE can be applied.
+					 */
+					entry->flags |= PGPA_TE_INAPPLICABLE;
+				}
+				else if (advice_unique != jt_unique)
+					jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+				else
+					jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			}
+			continue;
+		}
+	}
+
+	/*
+	 * If the advice indicates both that this join order is permissible and
+	 * also that it isn't, then mark advice related to the join order as
+	 * conflicting.
+	 */
+	if (jo_permit_indexes != NULL && jo_deny_indexes != NULL)
+	{
+		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * If more than one join method specification is relevant here and they
+	 * differ, mark them all as conflicting.
+	 */
+	if (jm_conflict)
+		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
+							 PGPA_TE_CONFLICTING);
+
+	/*
+	 * If we were advised to deny this join order, then do so. However, if we
+	 * were also advised to permit it, then do nothing, since the advice
+	 * conflicts.
+	 */
+	if (jo_deny_indexes != NULL && jo_permit_indexes == NULL)
+		*pgs_mask_p = 0;
+
+	/*
+	 * If we were advised to restrict the join method, then do so. However, if
+	 * we got conflicting join method advice or were also advised to reject
+	 * this join order completely, then instead do nothing.
+	 */
+	if (join_mask != 0 && !jm_conflict && jo_deny_indexes == NULL)
+		*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY) | join_mask;
+}
+
+/*
+ * Translate an advice tag into a path generation strategy mask.
+ *
+ * This function can be called with tag types that don't represent join
+ * strategies. In such cases, we just return 0, which can't be confused with
+ * a valid mask.
+ */
+static uint64
+pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag)
+{
+	switch (tag)
+	{
+		case PGPA_TAG_FOREIGN_JOIN:
+			return PGS_FOREIGNJOIN;
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return PGS_MERGEJOIN_PLAIN;
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return PGS_MERGEJOIN_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return PGS_NESTLOOP_PLAIN;
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return PGS_NESTLOOP_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return PGS_NESTLOOP_MEMOIZE;
+		case PGPA_TAG_HASH_JOIN:
+			return PGS_HASHJOIN;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Does a certain item of join order advice permit a certain join?
+ */
+static bool
+pgpa_join_order_permits_join(int outer_count, int inner_count,
+							 pgpa_identifier *rids,
+							 pgpa_trove_entry *entry)
+{
+	bool		loop = true;
+	bool		sublist = false;
+	int			length;
+	int			outer_length;
+	pgpa_advice_target *target = entry->target;
+	pgpa_advice_target *prefix_target;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * Find the innermost sublist that contains all keys; if no sublist does,
+	 * then continue processing with the toplevel list.
+	 *
+	 * For example, if the advice says JOIN_ORDER(t1 t2 (t3 t4 t5)), then we
+	 * should evaluate joins that only involve t3, t4, and/or t5 against the
+	 * (t3 t4 t5) sublist, and others against the full list.
+	 *
+	 * Note that (1) outermost sublist is always ordered and (2) whenever we
+	 * zoom into an unordered sublist, we instantly accept the proposed join.
+	 * If the advice says JOIN_ORDER(t1 t2 {t3 t4 t5}), any approach to
+	 * joining t3, t4, and/or t5 is acceptable.
+	 */
+	while (loop)
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+		loop = false;
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_itm_type itm;
+
+			if (child_target->ttype == PGPA_TARGET_IDENTIFIER)
+				continue;
+
+			itm = pgpa_identifiers_match_target(outer_count + inner_count,
+												rids, child_target);
+			if (itm == PGPA_ITM_EQUAL || itm == PGPA_ITM_KEYS_ARE_SUBSET)
+			{
+				if (child_target->ttype == PGPA_TARGET_ORDERED_LIST)
+				{
+					target = child_target;
+					sublist = true;
+					loop = true;
+					break;
+				}
+				else
+				{
+					Assert(child_target->ttype == PGPA_TARGET_UNORDERED_LIST);
+					return true;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Try to find a prefix of the selected join order list that is exactly
+	 * equal to the outer side of the proposed join.
+	 */
+	length = list_length(target->children);
+	prefix_target = palloc0_object(pgpa_advice_target);
+	prefix_target->ttype = PGPA_TARGET_ORDERED_LIST;
+	for (outer_length = 1; outer_length <= length; ++outer_length)
+	{
+		pgpa_itm_type itm;
+
+		/* Avoid leaking memory in every loop iteration. */
+		if (prefix_target->children != NULL)
+			list_free(prefix_target->children);
+		prefix_target->children = list_copy_head(target->children,
+												 outer_length);
+
+		/* Search, hoping to find an exact match. */
+		itm = pgpa_identifiers_match_target(outer_count, rids, prefix_target);
+		if (itm == PGPA_ITM_EQUAL)
+			break;
+
+		/*
+		 * If the prefix of the join order list that we're considering
+		 * includes some but not all of the outer rels, we can make the prefix
+		 * longer to find an exact match. But the advice hasn't mentioned
+		 * everything that's part of our outer rel yet, but has mentioned
+		 * things that are not, then this join doesn't match the join order
+		 * list.
+		 */
+		if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+			return false;
+	}
+
+	/*
+	 * If the previous looped stopped before the prefix_target included the
+	 * entire join order list, then the next member of the join order list
+	 * must exactly match the inner side of the join.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), if the outer side of the
+	 * current join includes only t1, then the inner side must be exactly t2;
+	 * if the outer side includes both t1 and t2, then the inner side must
+	 * include exactly t3, t4, and t5.
+	 */
+	if (outer_length < length)
+	{
+		pgpa_advice_target *inner_target;
+		pgpa_itm_type itm;
+
+		inner_target = list_nth(target->children, outer_length);
+
+		itm = pgpa_identifiers_match_target(inner_count, rids + outer_count,
+											inner_target);
+
+		/*
+		 * Before returning, consider whether we need to mark this entry as
+		 * fully matched. If we found every item but one on the lefthand side
+		 * of the join and the last item on the righthand side of the join,
+		 * then the answer is yes.
+		 */
+		if (outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
+			entry->flags |= PGPA_TE_MATCH_FULL;
+
+		return (itm == PGPA_ITM_EQUAL);
+	}
+
+	/*
+	 * If we get here, then the outer side of the join includes the entirety
+	 * of the join order list. In this case, we behave differently depending
+	 * on whether we're looking at the top-level join order list or sublist.
+	 * At the top-level, we treat the specified list as mandating that the
+	 * actual join order has the given list as a prefix, but a sublist
+	 * requires an exact match.
+	 *
+	 * Exmaple: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), we must start by joining
+	 * all five of those relations and in that sequence, but once that is
+	 * done, it's OK to join any other rels that are part of the join problem.
+	 * This allows a user to specify the driving table and perhaps the first
+	 * few things to which it should be joined while leaving the rest of the
+	 * join order up the optimizer. But it seems like it would be surprising,
+	 * given that specification, if the user could add t6 to the (t3 t4 t5)
+	 * sub-join, so we don't allow that. If we did want to allow it, the logic
+	 * earlier in this function would require substantial adjustment: we could
+	 * allow the t3-t4-t5-t6 join to be built here, but the next step of
+	 * joining t1-t2 to the result would still be rejected.
+	 */
+	return !sublist;
+}
+
+/*
+ * Does a certain item of join method advice permit a certain join?
+ *
+ * Advice such as HASH_JOIN((x y)) means that there should be a hash join with
+ * exactly x and y on the inner side. Obviously, this means that if we are
+ * considering a join with exactly x and y on the inner side, we should enforce
+ * the use of a hash join. However, it also means that we must reject some
+ * incompatible join orders entirely.  For example, a join with exactly x
+ * and y on the outer side shouldn't be allowed, because such paths might win
+ * over the advice-driven path on cost.
+ *
+ * To accommodate these requirements, this function returns true if the join
+ * should be allowed and false if it should not. Furthermore, *restrict_method
+ * is set to true if the join method should be enforced and false if not.
+ */
+static bool
+pgpa_join_method_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	/*
+	 * If our inner rel mentions exactly the same relations as the advice
+	 * target, allow the join and enforce the join method restriction.
+	 *
+	 * If our inner rel mentions a superset of the target relations, allow the
+	 * join. The join we care about has already taken place, and this advice
+	 * imposes no further restrictions.
+	 */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If our outer rel mentions a supserset of the relations in the advice
+	 * target, no restrictions apply. The join we care has already taken
+	 * place, and this advice imposes no further restrictions.
+	 *
+	 * On the other hand, if our outer rel mentions exactly the relations
+	 * mentioned in the advice target, the planner is trying to reverse the
+	 * sides of the join as compared with our desired outcome. Reject that.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+	else if (outer_itm == PGPA_ITM_EQUAL)
+		return false;
+
+	/*
+	 * If the advice target mentions only a single relation, the test below
+	 * cannot ever pass, so save some work by exiting now.
+	 */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+		return false;
+
+	/*
+	 * If everything in the joinrel is appears in the advice target, we're
+	 * below the level of the join we want to control.
+	 *
+	 * For example, HASH_JOIN((x y)) doesn't restrict how x and y can be
+	 * joined.
+	 *
+	 * This lookup shouldn't return PGPA_ITM_DISJOINT, because any such advice
+	 * should not have been returned from the trove in the first place.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've already permitted all allowable cases, so reject this.
+	 *
+	 * If we reach this point, then the advice overlaps with this join but
+	 * isn't entirely contained within either side, and there's also at least
+	 * one relation present in the join that isn't mentioned by the advice.
+	 *
+	 * For instance, in the HASH_JOIN((x y)) example, we would reach here if x
+	 * were on one side of the join, y on the other, and at least one of the
+	 * two sides also included some other relation, say t. In that case,
+	 * accepting this join would allow the (x y t) joinrel to contain
+	 * non-disabled paths that do not put (x y) on the inner side of a hash
+	 * join; we could instead end up with something like (x JOIN t) JOIN y.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning an opaque join permit a certain join?
+ *
+ * By an opaque join, we mean one where the exact mechanism by which the
+ * join is performed is not visible to PostgreSQL. Currently this is the
+ * case only for foreign joins: FOREIGN_JOIN((x y z)) means that x, y, and
+ * z are joined on the remote side, but we know nothing about the join order
+ * or join methods used over there.
+ */
+static bool
+pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	if (join_itm == PGPA_ITM_EQUAL)
+	{
+		/*
+		 * We have an exact match, and should therefore allow the join and
+		 * enforce the use of the relevant opaque join method.
+		 */
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+	{
+		/*
+		 * If join_itm == PGPA_ITM_TARGETS_ARE_SUBSET, then the join we care
+		 * about has already taken place and no further restrictions apply.
+		 *
+		 * If join_itm == PGPA_ITM_KEYS_ARE_SUBSET, we're still building up to
+		 * the join we care about and have not introduced any extraneous
+		 * relations not named in the advice. Note that ForeignScan paths for
+		 * joins are built up from ForeignScan paths from underlying joins and
+		 * scans, so we must not disable this join when considering a subset
+		 * of the relations we ultimately want.
+		 */
+		return true;
+	}
+
+	/*
+	 * The advice overlaps the join, but at least one relation is present in
+	 * the join that isn't mentioned by the advice. We want to disable such
+	 * paths so that we actually push down the join as intended.
+	 */
+	return false;
+}
+
+/*
+ * Apply scan advice to a RelOptInfo.
+ *
+ * XXX. For bitmap heap scans, we're just ignoring the index information from
+ * the advice. That's not cool.
+ */
+static void
+pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+							   pgpa_trove_entry *scan_entries,
+							   Bitmapset *scan_indexes,
+							   pgpa_trove_entry *rel_entries,
+							   Bitmapset *rel_indexes)
+{
+	bool		gather_conflict = false;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	int			i = -1;
+	pgpa_trove_entry *scan_entry = NULL;
+	int			flags;
+	bool		scan_type_conflict = false;
+	Bitmapset  *scan_type_indexes = NULL;
+	Bitmapset  *scan_type_rel_indexes = NULL;
+	uint64		gather_mask = 0;
+	uint64		scan_type = 0;
+
+	/* 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;
+
+		/* Translate our advice tags to a scan strategy advice value. */
+		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+			my_scan_type = PGS_BITMAPSCAN;
+		else if (my_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN)
+			my_scan_type = PGS_INDEXONLYSCAN | PGS_CONSIDER_INDEXONLY;
+		else if (my_entry->tag == PGPA_TAG_INDEX_SCAN)
+			my_scan_type = PGS_INDEXSCAN;
+		else if (my_entry->tag == PGPA_TAG_SEQ_SCAN)
+			my_scan_type = PGS_SEQSCAN;
+		else if (my_entry->tag == PGPA_TAG_TID_SCAN)
+			my_scan_type = PGS_TIDSCAN;
+
+		/*
+		 * If this is understandable scan advice, hang on to the entry, the
+		 * inferred scan type type, and the index at which we found it.
+		 *
+		 * Also make a note if we see conflicting scan type advice. Note that
+		 * we regard two index specifications as conflicting unless they match
+		 * exactly. In theory, perhaps we could regard INDEX_SCAN(a c) and
+		 * 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 (scan_type != 0 && scan_type != my_scan_type)
+				scan_type_conflict = true;
+			if (!scan_type_conflict && scan_entry != NULL &&
+				my_entry->target->itarget != NULL &&
+				scan_entry->target->itarget != NULL &&
+				!pgpa_index_targets_equal(scan_entry->target->itarget,
+										  my_entry->target->itarget))
+				scan_type_conflict = true;
+			scan_entry = my_entry;
+			scan_type = my_scan_type;
+			scan_type_indexes = bms_add_member(scan_type_indexes, i);
+		}
+	}
+
+	/* Scrutinize available gather-related and partitionwise advice. */
+	i = -1;
+	while ((i = bms_next_member(rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *my_entry = &rel_entries[i];
+		uint64		my_gather_mask = 0;
+		bool		just_one_rel;
+
+		just_one_rel = my_entry->target->ttype == PGPA_TARGET_IDENTIFIER
+			|| list_length(my_entry->target->children) == 1;
+
+		/*
+		 * PARTITIONWISE behaves like a scan type, except that if there's more
+		 * than one relation targeted, it has no effect at this level.
+		 */
+		if (my_entry->tag == PGPA_TAG_PARTITIONWISE)
+		{
+			if (just_one_rel)
+			{
+				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
+
+				if (scan_type != 0 && scan_type != my_scan_type)
+					scan_type_conflict = true;
+				scan_entry = my_entry;
+				scan_type = my_scan_type;
+				scan_type_rel_indexes =
+					bms_add_member(scan_type_rel_indexes, i);
+			}
+			continue;
+		}
+
+		/*
+		 * GATHER and GATHER_MERGE applied to a single rel mean that we should
+		 * use the correspondings strategy here, while applying either to more
+		 * than one rel means we should not use those strategies here, but
+		 * rather at the level of the joinrel that corresponds to what was
+		 * specified. NO_GATHER can only be applied to single rels.
+		 *
+		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
+		 * equivalent to allowing the non-use of either form of Gather here.
+		 */
+		if (my_entry->tag == PGPA_TAG_GATHER ||
+			my_entry->tag == PGPA_TAG_GATHER_MERGE)
+		{
+			if (!just_one_rel)
+				my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			else if (my_entry->tag == PGPA_TAG_GATHER)
+				my_gather_mask = PGS_GATHER;
+			else
+				my_gather_mask = PGS_GATHER_MERGE;
+		}
+		else if (my_entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			Assert(just_one_rel);
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (just_one_rel)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+	}
+
+	/* Enforce choice of index. */
+	if (scan_entry != NULL && !scan_type_conflict &&
+		(scan_entry->tag == PGPA_TAG_INDEX_SCAN ||
+		 scan_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN))
+	{
+		pgpa_index_target *itarget = scan_entry->target->itarget;
+		IndexOptInfo *matched_index = NULL;
+
+		Assert(itarget->itype == PGPA_INDEX_NAME);
+
+		foreach_node(IndexOptInfo, index, rel->indexlist)
+		{
+			char	   *relname = get_rel_name(index->indexoid);
+			Oid			nspoid = get_rel_namespace(index->indexoid);
+			char	   *relnamespace = get_namespace_name(nspoid);
+
+			if (strcmp(itarget->indname, relname) == 0 &&
+				(itarget->indnamespace == NULL ||
+				 strcmp(itarget->indnamespace, relnamespace) == 0))
+			{
+				matched_index = index;
+				break;
+			}
+		}
+
+		if (matched_index == NULL)
+		{
+			/* Don't force the scan type if the index doesn't exist. */
+			scan_type = 0;
+
+			/* Mark advice as inapplicable. */
+			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
+								 PGPA_TE_INAPPLICABLE);
+		}
+		else
+		{
+			/* Retain this index and discard the rest. */
+			rel->indexlist = list_make1(matched_index);
+		}
+	}
+
+	/*
+	 * Mark all the scan method entries as fully matched; and if they specify
+	 * different things, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	if (scan_type_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
+	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched. Mark
+	 * the ones that included this relation as a target by itself as fully
+	 * matched. If there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
+
+	/* If there is a non-conflicting scan specification, enforce it. */
+	if (scan_type != 0 && !scan_type_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
+			  PGS_CONSIDER_INDEXONLY);
+		rel->pgs_mask |= scan_type;
+	}
+
+	/* If there is a non-conflicting gather specification, enforce it. */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		rel->pgs_mask &=
+			~(PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL);
+		rel->pgs_mask |= gather_mask;
+	}
+}
+
+/*
+ * Add feedback entries to for one trove slice to the provided list and
+ * return the resulting list.
+ *
+ * Feedback entries are generated from the trove entry's flags. It's assumed
+ * that the caller has already set all relevant flags with the exception of
+ * PGPA_TE_FAILED. We set that flag here if appropriate.
+ */
+static List *
+pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+							 pgpa_trove_lookup_type type,
+							 pgpa_identifier *rt_identifiers,
+							 pgpa_plan_walker_context *walker)
+{
+	pgpa_trove_entry *entries;
+	int			nentries;
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	pgpa_trove_lookup_all(trove, type, &entries, &nentries);
+	for (int i = 0; i < nentries; ++i)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+		DefElem    *item;
+
+		/*
+		 * If this entry was fully matched, check whether generating advice
+		 * from this plan would produce such an entry. If not, label the entry
+		 * as failed.
+		 */
+		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+			!pgpa_walker_would_advise(walker, rt_identifiers,
+									  entry->tag, entry->target))
+			entry->flags |= PGPA_TE_FAILED;
+
+		item = makeDefElem(pgpa_cstring_trove_entry(entry),
+						   (Node *) makeInteger(entry->flags), -1);
+		list = lappend(list, item);
+	}
+
+	return list;
+}
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * Fast hash function for a key consisting of an RTI and plan name.
+ */
+static uint32
+pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	hs.accum = key.rti;
+	fasthash_combine(&hs);
+
+	/* plan_name can be NULL */
+	if (key.plan_name == NULL)
+		sp_len = 0;
+	else
+		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+
+	/* hashfn_unstable.h recommends using string length as tweak */
+	return fasthash_final32(&hs, sp_len);
+}
+
+#endif
+
+/*
+ * Save the range table identifier for one relation for future cross-checking.
+ */
+static void
+pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
+					 RelOptInfo *rel)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_checker_key key;
+	pgpa_ri_checker *check;
+	pgpa_identifier rid;
+	const char *rid_string;
+	bool		found;
+
+	key.rti = bms_singleton_member(rel->relids);
+	key.plan_name = root->plan_name;
+	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
+	rid_string = pgpa_identifier_string(&rid);
+	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
+	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
+	check->rid_string = rid_string;
+#endif
+}
+
+/*
+ * 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_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	pgpa_ri_check_iterator it;
+	pgpa_ri_checker *check;
+
+	/* 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. */
+	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
+	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	{
+		int			rtoffset = 0;
+		const char *rid_string;
+		Index		flat_rti;
+
+		/*
+		 * 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.
+		 */
+		if (check->key.plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				/*
+				 * If rtinfo->dummy is set, then the subquery's range table
+				 * will only have been partially copied to the final range
+				 * table. Specifically, only RTE_RELATION entries and
+				 * 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
+				 * in the final rtable.
+				 *
+				 * With more complex logic, we could work around that problem
+				 * by remembering the whole contents of the subquery's rtable
+				 * during planning, determining which of those would have been
+				 * copied to the final rtable, and matching them up. But it
+				 * doesn't seem like a worthwhile endeavor for right now,
+				 * because RTIs from such subqueries won't appear in the plan
+				 * tree itself, just in the range table. Hence, we can neither
+				 * generate nor accept advice for them.
+				 */
+				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+					&& !rtinfo->dummy)
+				{
+					rtoffset = rtinfo->rtoffset;
+					Assert(rtoffset > 0);
+					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;
+		}
+
+		/*
+		 * check->key.rti is the RTI that we saw prior to range-table
+		 * flattening, so we must add the appropriate RT offset to get the
+		 * final RTI.
+		 */
+		flat_rti = check->key.rti + rtoffset;
+		Assert(flat_rti <= list_length(pstmt->rtable));
+
+		/* Assert that the string we compute now matches the previous one. */
+		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
+		Assert(strcmp(rid_string, check->rid_string) == 0);
+	}
+#endif
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
new file mode 100644
index 00000000000..7d40b910b00
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -0,0 +1,17 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.h
+ *	  planner hooks
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_PLANNER_H
+#define PGPA_PLANNER_H
+
+extern void pgpa_planner_install_hooks(void);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
new file mode 100644
index 00000000000..4156f5209a0
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -0,0 +1,303 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.c
+ *	  analysis of scans in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+
+static pgpa_scan *pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								 pgpa_scan_strategy strategy,
+								 Bitmapset *relids,
+								 bool needs_no_gather);
+
+
+static RTEKind unique_nonjoin_rtekind(Bitmapset *relids, List *rtable);
+
+/*
+ * Build a pgpa_scan object for a Plan node and update the plan walker
+ * context as appopriate.  If this is an Append or MergeAppend scan, also
+ * build pgpa_scan for any scans that were consolidated into this one by
+ * Append/MergeAppend pull-up.
+ *
+ * If there is at least one ElidedNode for this plan node, pass the uppermost
+ * one as elided_node, else pass NULL.
+ *
+ * Set the 'beneath_any_gather' node if we are underneath a Gather or
+ * Gather Merge node.
+ *
+ * Set the 'within_join_problem' flag if we're inside of a join problem and
+ * not otherwise.
+ */
+pgpa_scan *
+pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+				ElidedNode *elided_node,
+				bool beneath_any_gather, bool within_join_problem)
+{
+	pgpa_scan_strategy strategy = PGPA_SCAN_ORDINARY;
+	Bitmapset  *relids = NULL;
+	int			rti = -1;
+	bool		needs_no_gather = !beneath_any_gather;
+	List	   *child_append_relid_sets = NIL;
+
+	if (elided_node != NULL)
+	{
+		NodeTag		elided_type = elided_node->elided_type;
+
+		/*
+		 * If setrefs processing elided an Append or MergeAppend node that had
+		 * only one surviving child, then this is a partitionwise "scan" --
+		 * which may really be a partitionwise join, but there's no need to
+		 * distinguish.
+		 *
+		 * If it's a trivial SubqueryScan that was elided, then this is an
+		 * "ordinary" scan i.e. one for which we need to generate advice
+		 * because the planner has not made any meaningful choice.
+		 */
+		relids = elided_node->relids;
+		if (elided_type == T_Append || elided_type == T_MergeAppend)
+			strategy = PGPA_SCAN_PARTITIONWISE;
+		else
+			strategy = PGPA_SCAN_ORDINARY;
+
+		/*
+		 * If this is an elided Append or MergeAppend node, we don't need to
+		 * emit NO_GATHER() because we'll also emit it for the underlying
+		 * scan, which is good enough.
+		 *
+		 * If it's an elided SubqueryScan, the same argument is likely to
+		 * apply, because the subquery probably contains references to tables,
+		 * and if it doesn't, then planner isn't likely to want to do it in
+		 * parallel anyway. But also, we can't implement NO_GATHER() advice
+		 * for a non-relation RTEKind, because get_relation_info() isn't
+		 * called in such cases. That should probably be fixed at some point,
+		 * but until then we shouldn't emit it.
+		 */
+		needs_no_gather = false;
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+	{
+		RangeTblEntry *rte;
+
+		relids = bms_make_singleton(rti);
+
+		switch (nodeTag(plan))
+		{
+			case T_SeqScan:
+				strategy = PGPA_SCAN_SEQ;
+				break;
+			case T_BitmapHeapScan:
+				strategy = PGPA_SCAN_BITMAP_HEAP;
+				break;
+			case T_IndexScan:
+				strategy = PGPA_SCAN_INDEX;
+				break;
+			case T_IndexOnlyScan:
+				strategy = PGPA_SCAN_INDEX_ONLY;
+				break;
+			case T_TidScan:
+			case T_TidRangeScan:
+				strategy = PGPA_SCAN_TID;
+				break;
+			default:
+
+				/*
+				 * This case includes a ForeignScan targeting a single
+				 * relation; no other strategy is possible in that case, but
+				 * see below, where things are different in multi-relation
+				 * cases.
+				 */
+				strategy = PGPA_SCAN_ORDINARY;
+
+				/*
+				 * We can't handle NO_GATHER() for non-relation RTEs because
+				 * get_relation_info won't be called.
+				 */
+				rte = rt_fetch(rti, walker->pstmt->rtable);
+				if (rte->rtekind != RTE_RELATION)
+					needs_no_gather = false;
+
+				break;
+		}
+	}
+	else if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		switch (nodeTag(plan))
+		{
+			case T_ForeignScan:
+
+				/*
+				 * If multiple relations are being targeted by a single
+				 * foreign scan, then the foreign join has been pushed to the
+				 * remote side, and we want that to be reflected in the
+				 * generated advice.
+				 */
+				strategy = PGPA_SCAN_FOREIGN;
+				break;
+			case T_Append:
+
+				/*
+				 * Append nodes can represent partitionwise scans of a a
+				 * relation, but when they implement a set operation, they are
+				 * just ordinary scans.
+				 */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+
+				/* NO_GATHER() should be emitted for underlying rels only. */
+				needs_no_gather = false;
+
+				/* Be sure to account for pulled-up scans. */
+				child_append_relid_sets =
+					((Append *) plan)->child_append_relid_sets;
+				break;
+			case T_MergeAppend:
+				/* Some logic here as for Append, above. */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+
+				/* NO_GATHER() should be emitted for underlying rels only. */
+				needs_no_gather = false;
+
+				/* Be sure to account for pulled-up scans. */
+				child_append_relid_sets =
+					((MergeAppend *) plan)->child_append_relid_sets;
+				break;
+			default:
+				strategy = PGPA_SCAN_ORDINARY;
+
+				/*
+				 * We can't handle NO_GATHER() for single non-relation RTEs
+				 * because get_relation_info won't be called.
+				 */
+				if (bms_membership(relids) == BMS_SINGLETON)
+				{
+					RangeTblEntry *rte;
+
+					rte = rt_fetch(bms_singleton_member(relids),
+								   walker->pstmt->rtable);
+					if (rte->rtekind != RTE_RELATION)
+						needs_no_gather = false;
+				}
+
+				break;
+		}
+
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+
+	/*
+	 * If this is an Append or MergeAppend node into which subordinate Append
+	 * or MergeAppend paths were merged, each of those merged paths is
+	 * effectively another scan for which we need to account.
+	 */
+	foreach_node(Bitmapset, child_relids, child_append_relid_sets)
+	{
+		Bitmapset  *child_nonjoin_relids;
+
+		child_nonjoin_relids =
+			pgpa_filter_out_join_relids(child_relids,
+										walker->pstmt->rtable);
+		(void) pgpa_make_scan(walker, plan, strategy,
+							  child_nonjoin_relids, false);
+	}
+
+	/*
+	 * If this plan node has no associated RTIs, it's not a scan. When the
+	 * 'within_join_problem' flag is set, that's unexpected, so throw an
+	 * error, else return quietly.
+	 */
+	if (relids == NULL)
+	{
+		if (within_join_problem)
+			elog(ERROR, "plan node has no RTIs: %d", (int) nodeTag(plan));
+		return NULL;
+	}
+
+	return pgpa_make_scan(walker, plan, strategy, relids, needs_no_gather);
+}
+
+/*
+ * Create a single pgpa_scan object and update the pgpa_plan_walker_context.
+ */
+static pgpa_scan *
+pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+			   pgpa_scan_strategy strategy, Bitmapset *relids,
+			   bool needs_no_gather)
+{
+	pgpa_scan  *scan;
+
+	/* Create the scan object. */
+	scan = palloc(sizeof(pgpa_scan));
+	scan->plan = plan;
+	scan->strategy = strategy;
+	scan->relids = relids;
+
+	/* Add it to the appropriate list. */
+	walker->scans[scan->strategy] = lappend(walker->scans[scan->strategy],
+											scan);
+
+	/* Caller tells us whether NO_GATHER() advice for this scan is needed. */
+	if (needs_no_gather)
+		walker->no_gather_scans = bms_add_members(walker->no_gather_scans,
+												  scan->relids);
+
+	return scan;
+}
+
+/*
+ * Determine the unique rtekind of a set of relids.
+ */
+static RTEKind
+unique_nonjoin_rtekind(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	bool		first = true;
+	RTEKind		rtekind;
+
+	Assert(relids != NULL);
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		if (first)
+		{
+			rtekind = rte->rtekind;
+			first = false;
+		}
+		else if (rtekind != rte->rtekind)
+			elog(ERROR, "rtekind mismatch: %d vs. %d",
+				 rtekind, rte->rtekind);
+	}
+
+	if (first)
+		elog(ERROR, "no non-RTE_JOIN RTEs found");
+
+	return rtekind;
+}
diff --git a/contrib/pg_plan_advice/pgpa_scan.h b/contrib/pg_plan_advice/pgpa_scan.h
new file mode 100644
index 00000000000..3bb8726ff1e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.h
@@ -0,0 +1,85 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.h
+ *	  analysis of scans in Plan trees
+ *
+ * For purposes of this module, a "scan" includes (1) single plan nodes that
+ * scan multiple RTIs, such as a degenerate Result node that replaces what
+ * would otherwise have been a join, and (2) Append and MergeAppend nodes
+ * implementing a partitionwise scan or a partitionwise join. Said
+ * differently, scans are the leaves of the join tree for a single join
+ * problem.
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_SCAN_H
+#define PGPA_SCAN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+
+/*
+ * Scan strategies.
+ *
+ * PGPA_SCAN_ORDINARY is any scan strategy that isn't interesting to us
+ * because there is no meaningful planner decision involved. For example,
+ * the only way to scan a subquery is a SubqueryScan, and the only way to
+ * scan a VALUES construct is a ValuesScan. We need not care exactly which
+ * type of planner node was used in such cases, because the same thing will
+ * happen when replanning.
+ *
+ * PGPA_SCAN_ORDINARY also includes Result nodes that correspond to scans
+ * or even joins that are proved empty. We don't know whether or not the scan
+ * or join will still be provably empty at replanning time, but if it is,
+ * then no scan-type advice is needed, and if it's not, we can't recommend
+ * a scan type based on the current plan.
+ *
+ * PGPA_SCAN_PARTITIONWISE also lumps together scans and joins: this can
+ * be either a partitionwise scan of a partitioned table or a partitionwise
+ * join between several partitioned tables. Note that all decisions about
+ * whether or not to use partitionwise join are meaningful: no matter what
+ * we decided this time, we could do more or fewer things partitionwise the
+ * next time.
+ *
+ * PGPA_SCAN_FOREIGN is only used when there's more than one relation involved;
+ * a single-table foreign scan is classified as ordinary, since there is no
+ * decision to make in that case.
+ *
+ * Other scan strategies map one-to-one to plan nodes.
+ */
+typedef enum
+{
+	PGPA_SCAN_ORDINARY = 0,
+	PGPA_SCAN_SEQ,
+	PGPA_SCAN_BITMAP_HEAP,
+	PGPA_SCAN_FOREIGN,
+	PGPA_SCAN_INDEX,
+	PGPA_SCAN_INDEX_ONLY,
+	PGPA_SCAN_PARTITIONWISE,
+	PGPA_SCAN_TID
+	/* update NUM_PGPA_SCAN_STRATEGY if you add anything here */
+} pgpa_scan_strategy;
+
+#define NUM_PGPA_SCAN_STRATEGY	((int) PGPA_SCAN_TID + 1)
+
+/*
+ * All of the details we need regarding a scan.
+ */
+typedef struct pgpa_scan
+{
+	Plan	   *plan;
+	pgpa_scan_strategy strategy;
+	Bitmapset  *relids;
+} pgpa_scan;
+
+extern pgpa_scan *pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								  ElidedNode *elided_node,
+								  bool beneath_any_gather,
+								  bool within_join_problem);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scanner.l b/contrib/pg_plan_advice/pgpa_scanner.l
new file mode 100644
index 00000000000..c49b29d906d
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scanner.l
@@ -0,0 +1,302 @@
+%top{
+/*
+ * Scanner for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_scanner.l
+ */
+#include "postgres.h"
+
+#include "common/string.h"
+#include "nodes/miscnodes.h"
+#include "parser/scansup.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Extra data that we pass around when during scanning.
+ *
+ * 'litbuf' is used to implement the <xd> exclusive state, which handles
+ * double-quoted identifiers.
+ */
+typedef struct pgpa_yy_extra_type
+{
+	StringInfoData	litbuf;
+} pgpa_yy_extra_type;
+
+}
+
+%{
+/* LCOV_EXCL_START */
+
+#define YY_DECL \
+	extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result, \
+						  char **parse_error_msg_p, yyscan_t yyscanner)
+
+/* No reason to constrain amount of data slurped */
+#define YY_READ_BUF_SIZE 16777216
+
+/* Avoid exit() on fatal scanner errors (a bit ugly -- see yy_fatal_error) */
+#undef fprintf
+#define fprintf(file, fmt, msg)  fprintf_to_ereport(fmt, msg)
+
+static void
+fprintf_to_ereport(const char *fmt, const char *msg)
+{
+	ereport(ERROR, (errmsg_internal("%s", msg)));
+}
+%}
+
+%option reentrant
+%option bison-bridge
+%option 8bit
+%option never-interactive
+%option nodefault
+%option noinput
+%option nounput
+%option noyywrap
+%option noyyalloc
+%option noyyrealloc
+%option noyyfree
+%option warn
+%option prefix="pgpa_yy"
+%option extra-type="pgpa_yy_extra_type *"
+
+/*
+ * What follows is a severely stripped-down version of the core scanner. We
+ * only care about recognizing identifiers with or without identifier quoting
+ * (i.e. double-quoting), decimal integers, and a small handful of other
+ * things. Keep these rules in sync with src/backend/parser/scan.l. As in that
+ * file, we use an exclusive state called 'xc' for C-style comments, and an
+ * exclusive state called 'xd' for double-quoted identifiers.
+ */
+%x xc
+%x xd
+
+ident_start		[A-Za-z\200-\377_]
+ident_cont		[A-Za-z\200-\377_0-9\$]
+
+identifier		{ident_start}{ident_cont}*
+
+decdigit		[0-9]
+decinteger		{decdigit}(_?{decdigit})*
+
+space			[ \t\n\r\f\v]
+whitespace		{space}+
+
+dquote			\"
+xdstart			{dquote}
+xdstop			{dquote}
+xddouble		{dquote}{dquote}
+xdinside		[^"]+
+
+xcstart			\/\*
+xcstop			\*+\/
+xcinside		[^*/]+
+
+%%
+
+{whitespace}	{ /* ignore */ }
+
+{identifier}	{
+					char   *str;
+					bool	fail;
+					pgpa_advice_tag_type	tag;
+
+					/*
+					 * Unlike the core scanner, we don't truncate identifiers
+					 * here. There is no obvious reason to do so.
+					 */
+					str = downcase_identifier(yytext, yyleng, false, false);
+					yylval->str = str;
+
+					/*
+					 * If it's not a tag, just return TOK_IDENT; else, return
+					 * a token type based on how further parsing should
+					 * proceed.
+					 */
+					tag = pgpa_parse_advice_tag(str, &fail);
+					if (fail)
+						return TOK_IDENT;
+					else if (tag == PGPA_TAG_JOIN_ORDER)
+						return TOK_TAG_JOIN_ORDER;
+					else if (tag == PGPA_TAG_INDEX_SCAN ||
+							 tag == PGPA_TAG_INDEX_ONLY_SCAN)
+						return TOK_TAG_INDEX;
+					else if (tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+						return TOK_TAG_BITMAP;
+					else if (tag == PGPA_TAG_SEQ_SCAN ||
+							 tag == PGPA_TAG_TID_SCAN ||
+							 tag == PGPA_TAG_NO_GATHER)
+						return TOK_TAG_SIMPLE;
+					else
+						return TOK_TAG_GENERIC;
+				}
+
+{decinteger}	{
+					char   *endptr;
+
+					errno = 0;
+					yylval->integer = strtoint(yytext, &endptr, 10);
+					if (*endptr != '\0' || errno == ERANGE)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "integer out of range");
+					return TOK_INTEGER;
+				}
+
+{xcstart}		{
+					BEGIN(xc);
+				}
+
+{xdstart}		{
+					BEGIN(xd);
+					resetStringInfo(&yyextra->litbuf);
+				}
+
+"||"			{ return TOK_OR; }
+
+"&&"			{ return TOK_AND; }
+
+.				{ return yytext[0]; }
+
+<xc>{xcstop}	{
+					BEGIN(INITIAL);
+				}
+
+<xc>{xcinside}	{
+					/* discard multiple characters without slash or asterisk */
+				}
+
+<xc>.			{
+					/*
+					 * Discard any single character. flex prefers longer
+					 * matches, so this rule will never be picked when we could
+					 * have matched xcstop.
+					 *
+					 * NB: At present, we don't bother to support nested
+					 * C-style comments here, but this logic could be extended
+					 * if that restriction poses a problem.
+					 */
+				}
+
+<xc><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated comment");
+				}
+
+<xd>{xdstop}	{
+					BEGIN(INITIAL);
+					if (yyextra->litbuf.len == 0)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "zero-length delimited identifier");
+					yylval->str = pstrdup(yyextra->litbuf.data);
+					return TOK_IDENT;
+				}
+
+<xd>{xddouble}	{
+					appendStringInfoChar(&yyextra->litbuf, '"');
+				}
+
+<xd>{xdinside}	{
+					appendBinaryStringInfo(&yyextra->litbuf, yytext, yyleng);
+				}
+
+<xd><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated quoted identifier");
+				}
+
+%%
+
+/* LCOV_EXCL_STOP */
+
+/*
+ * Handler for errors while scanning or parsing advice.
+ *
+ * bison passes the error message to us via 'message', and the context is
+ * available via the 'yytext' macro. We assemble those values into a final
+ * error text and then arrange to pass it back to the caller of pgpa_yyparse()
+ * by storing it into *parse_error_msg_p.
+ */
+void
+pgpa_yyerror(List **result, char **parse_error_msg_p, yyscan_t yyscanner,
+			 const char *message)
+{
+	struct yyguts_t *yyg = (struct yyguts_t *) yyscanner;	/* needed for yytext
+															 * macro */
+
+
+	/* report only the first error in a parse operation */
+	if (*parse_error_msg_p)
+		return;
+
+	if (yytext[0])
+		*parse_error_msg_p = psprintf("%s at or near \"%s\"", message, yytext);
+	else
+		*parse_error_msg_p = psprintf("%s at end of input", message);
+}
+
+/*
+ * Initialize the advice scanner.
+ *
+ * This should be called before parsing begins.
+ */
+void
+pgpa_scanner_init(const char *str, yyscan_t *yyscannerp)
+{
+	yyscan_t	yyscanner;
+	pgpa_yy_extra_type	*yyext = palloc0_object(pgpa_yy_extra_type);
+
+	if (yylex_init(yyscannerp) != 0)
+		elog(ERROR, "yylex_init() failed: %m");
+
+	yyscanner = *yyscannerp;
+
+	initStringInfo(&yyext->litbuf);
+	pgpa_yyset_extra(yyext, yyscanner);
+
+	yy_scan_string(str, yyscanner);
+}
+
+
+/*
+ * Shut down the advice scanner.
+ *
+ * This should be called after parsing is complete.
+ */
+void
+pgpa_scanner_finish(yyscan_t yyscanner)
+{
+	yylex_destroy(yyscanner);
+}
+
+/*
+ * Interface functions to make flex use palloc() instead of malloc().
+ * It'd be better to make these static, but flex insists otherwise.
+ */
+
+void *
+yyalloc(yy_size_t size, yyscan_t yyscanner)
+{
+	return palloc(size);
+}
+
+void *
+yyrealloc(void *ptr, yy_size_t size, yyscan_t yyscanner)
+{
+	if (ptr)
+		return repalloc(ptr, size);
+	else
+		return palloc(size);
+}
+
+void
+yyfree(void *ptr, yyscan_t yyscanner)
+{
+	if (ptr)
+		pfree(ptr);
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
new file mode 100644
index 00000000000..9db1077d487
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -0,0 +1,492 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.c
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * This name comes from the English expression "trove of advice", which
+ * means a collection of wisdom. This slightly unusual term is chosen to
+ * avoid naming confusion; for example, "collection of advice" would
+ * invite confusion with pgpa_collector.c. Note that, while we don't know
+ * whether the provided advice is actually wise, it's not our job to
+ * question the user's choices.
+ *
+ * The goal of this module is to make it easy to locate the specific
+ * bits of advice that pertain to any given part of a query, or to
+ * determine that there are none.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_trove.h"
+
+#include "common/hashfn_unstable.h"
+
+/*
+ * An advice trove is organized into a series of "slices", each of which
+ * contains information about one topic e.g. scan methods. Each slice consists
+ * of an array of trove entries plus a hash table that we can use to determine
+ * which ones are relevant to a particular part of the query.
+ */
+typedef struct pgpa_trove_slice
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	pgpa_trove_entry *entries;
+	struct pgpa_trove_entry_hash *hash;
+} pgpa_trove_slice;
+
+/*
+ * Scan advice is stored into 'scan'; join advice is stored into 'join'; and
+ * advice that can apply to both cases is stored into 'rel'. This lets callers
+ * ask just for what's relevant. These slices correspond to the possible values
+ * of pgpa_trove_lookup_type.
+ */
+struct pgpa_trove
+{
+	pgpa_trove_slice join;
+	pgpa_trove_slice rel;
+	pgpa_trove_slice scan;
+};
+
+/*
+ * We're going to build a hash table to allow clients of this module to find
+ * relevant advice for a given part of the query quickly. However, we're going
+ * to use only three of the five key fields as hash keys. There are two reasons
+ * for this.
+ *
+ * First, it's allowable to set partition_schema to NULL to match a partition
+ * with the correct name in any schema.
+ *
+ * Second, we expect the "occurrence" and "partition_schema" portions of the
+ * relation identifiers to be mostly uninteresting. Most of the time, the
+ * occurrence field will be 1 and the partition_schema values will all be the
+ * same. Even when there is some variation, the absolute number of entries
+ * that have the same values for all three of these key fields should be
+ * quite small.
+ */
+typedef struct
+{
+	const char *alias_name;
+	const char *partition_name;
+	const char *plan_name;
+} pgpa_trove_entry_key;
+
+typedef struct
+{
+	pgpa_trove_entry_key key;
+	int			status;
+	Bitmapset  *indexes;
+} pgpa_trove_entry_element;
+
+static uint32 pgpa_trove_entry_hash_key(pgpa_trove_entry_key key);
+
+static inline bool
+pgpa_trove_entry_compare_key(pgpa_trove_entry_key a, pgpa_trove_entry_key b)
+{
+	if (strcmp(a.alias_name, b.alias_name) != 0)
+		return false;
+
+	if (!strings_equal_or_both_null(a.partition_name, b.partition_name))
+		return false;
+
+	if (!strings_equal_or_both_null(a.plan_name, b.plan_name))
+		return false;
+
+	return true;
+}
+
+#define SH_PREFIX			pgpa_trove_entry
+#define SH_ELEMENT_TYPE		pgpa_trove_entry_element
+#define SH_KEY_TYPE			pgpa_trove_entry_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_trove_entry_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_trove_entry_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static void pgpa_init_trove_slice(pgpa_trove_slice *tslice);
+static void pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+									pgpa_advice_tag_type tag,
+									pgpa_advice_target *target);
+static void pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash,
+								   pgpa_advice_target *target,
+								   int index);
+static Bitmapset *pgpa_trove_slice_lookup(pgpa_trove_slice *tslice,
+										  pgpa_identifier *rid);
+
+/*
+ * Build a trove of advice from a list of advice items.
+ *
+ * Caller can obtain a list of advice items to pass to this function by
+ * calling pgpa_parse().
+ */
+pgpa_trove *
+pgpa_build_trove(List *advice_items)
+{
+	pgpa_trove *trove = palloc_object(pgpa_trove);
+
+	pgpa_init_trove_slice(&trove->join);
+	pgpa_init_trove_slice(&trove->rel);
+	pgpa_init_trove_slice(&trove->scan);
+
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		switch (item->tag)
+		{
+			case PGPA_TAG_JOIN_ORDER:
+				{
+					pgpa_advice_target *target;
+
+					/*
+					 * For most advice types, each element in the top-level
+					 * list is a separate target, but it's most convenient to
+					 * regard the entirety of a JOIN_ORDER specification as a
+					 * single target. Since it wasn't represented that way
+					 * during parsing, build a surrogate object now.
+					 */
+					target = palloc0_object(pgpa_advice_target);
+					target->ttype = PGPA_TARGET_ORDERED_LIST;
+					target->children = item->targets;
+
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_INDEX_ONLY_SCAN:
+			case PGPA_TAG_INDEX_SCAN:
+			case PGPA_TAG_SEQ_SCAN:
+			case PGPA_TAG_TID_SCAN:
+
+				/*
+				 * Scan advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					/*
+					 * For now, all of our scan types target single relations,
+					 * but in the future this might not be true, e.g. a custom
+					 * scan could replace a join.
+					 */
+					Assert(target->ttype == PGPA_TARGET_IDENTIFIER);
+					pgpa_trove_add_to_slice(&trove->scan,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_FOREIGN_JOIN:
+			case PGPA_TAG_HASH_JOIN:
+			case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			case PGPA_TAG_MERGE_JOIN_PLAIN:
+			case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			case PGPA_TAG_NESTED_LOOP_PLAIN:
+			case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			case PGPA_TAG_SEMIJOIN_UNIQUE:
+
+				/*
+				 * Join strategy advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_PARTITIONWISE:
+			case PGPA_TAG_GATHER:
+			case PGPA_TAG_GATHER_MERGE:
+			case PGPA_TAG_NO_GATHER:
+
+				/*
+				 * Advice about a RelOptInfo relevant to both scans and joins.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->rel,
+											item->tag, target);
+				}
+				break;
+		}
+	}
+
+	return trove;
+}
+
+/*
+ * Search a trove of advice for relevant entries.
+ *
+ * All parameters are input parameters except for *result, which is an output
+ * parameter used to return results to the caller.
+ */
+void
+pgpa_trove_lookup(pgpa_trove *trove, pgpa_trove_lookup_type type,
+				  int nrids, pgpa_identifier *rids, pgpa_trove_result *result)
+{
+	pgpa_trove_slice *tslice;
+	Bitmapset  *indexes;
+
+	Assert(nrids > 0);
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	indexes = pgpa_trove_slice_lookup(tslice, &rids[0]);
+	for (int i = 1; i < nrids; ++i)
+	{
+		Bitmapset  *other_indexes;
+
+		/*
+		 * If the caller is asking about two relations that aren't part of the
+		 * same subquery, they've messed up.
+		 */
+		Assert(strings_equal_or_both_null(rids[0].plan_name,
+										  rids[i].plan_name));
+
+		other_indexes = pgpa_trove_slice_lookup(tslice, &rids[i]);
+		indexes = bms_union(indexes, other_indexes);
+	}
+
+	result->entries = tslice->entries;
+	result->indexes = indexes;
+}
+
+/*
+ * Return all entries in a trove slice to the caller.
+ *
+ * The first two arguments are input arguments, and the remainder are output
+ * arguments.
+ */
+void
+pgpa_trove_lookup_all(pgpa_trove *trove, pgpa_trove_lookup_type type,
+					  pgpa_trove_entry **entries, int *nentries)
+{
+	pgpa_trove_slice *tslice;
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	*entries = tslice->entries;
+	*nentries = tslice->nused;
+}
+
+/*
+ * Convert a trove entry to an item of plan advice that would produce it.
+ */
+char *
+pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	appendStringInfo(&buf, "%s", pgpa_cstring_advice_tag(entry->tag));
+
+	/* JOIN_ORDER tags are transformed by pgpa_build_trove; undo that here */
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, '(');
+	else
+		Assert(entry->target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	pgpa_format_advice_target(&buf, entry->target);
+
+	if (entry->target->itarget != NULL)
+	{
+		appendStringInfoChar(&buf, ' ');
+		pgpa_format_index_target(&buf, entry->target->itarget);
+	}
+
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, ')');
+
+	return buf.data;
+}
+
+/*
+ * Set PGPA_TE_* flags on a set of trove entries.
+ */
+void
+pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
+{
+	int			i = -1;
+
+	while ((i = bms_next_member(indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+
+		entry->flags |= flags;
+	}
+}
+
+/*
+ * Add a new advice target to an existing pgpa_trove_slice object.
+ */
+static void
+pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+						pgpa_advice_tag_type tag,
+						pgpa_advice_target *target)
+{
+	pgpa_trove_entry *entry;
+
+	if (tslice->nused >= tslice->nallocated)
+	{
+		int			new_allocated;
+
+		new_allocated = tslice->nallocated * 2;
+		tslice->entries = repalloc_array(tslice->entries, pgpa_trove_entry,
+										 new_allocated);
+		tslice->nallocated = new_allocated;
+	}
+
+	entry = &tslice->entries[tslice->nused];
+	entry->tag = tag;
+	entry->target = target;
+	entry->flags = 0;
+
+	pgpa_trove_add_to_hash(tslice->hash, target, tslice->nused);
+
+	tslice->nused++;
+}
+
+/*
+ * Update the hash table for a newly-added advice target.
+ */
+static void
+pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash, pgpa_advice_target *target,
+					   int index)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	bool		found;
+
+	/* For non-identifiers, add entries for all descendents. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_trove_add_to_hash(hash, child_target, index);
+		}
+		return;
+	}
+
+	/* Sanity checks. */
+	Assert(target->rid.occurrence > 0);
+	Assert(target->rid.alias_name != NULL);
+
+	/* Add an entry for this relation identifier. */
+	key.alias_name = target->rid.alias_name;
+	key.partition_name = target->rid.partrel;
+	key.plan_name = target->rid.plan_name;
+	element = pgpa_trove_entry_insert(hash, key, &found);
+	if (!found)
+		element->indexes = NULL;
+	element->indexes = bms_add_member(element->indexes, index);
+}
+
+/*
+ * Create and initialize a new pgpa_trove_slice object.
+ */
+static void
+pgpa_init_trove_slice(pgpa_trove_slice *tslice)
+{
+	/*
+	 * In an ideal world, we'll make tslice->nallocated big enough that the
+	 * array and hash table will be large enough to contain the number of
+	 * advice items in this trove slice, but a generous default value is not
+	 * good for performance, because pgpa_init_trove_slice() has to zero an
+	 * amount of memory proportional to tslice->nallocated. Hence, we keep the
+	 * starting value quite small, on the theory that advice strings will
+	 * often be relatively short.
+	 */
+	tslice->nallocated = 16;
+	tslice->nused = 0;
+	tslice->entries = palloc_array(pgpa_trove_entry, tslice->nallocated);
+	tslice->hash = pgpa_trove_entry_create(CurrentMemoryContext,
+										   tslice->nallocated, NULL);
+}
+
+/*
+ * Fast hash function for a key consisting of alias_name, partition_name,
+ * and plan_name.
+ */
+static uint32
+pgpa_trove_entry_hash_key(pgpa_trove_entry_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	/* alias_name may not be NULL */
+	sp_len = fasthash_accum_cstring(&hs, key.alias_name);
+
+	/* partition_name and plan_name, however, can be NULL */
+	if (key.partition_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.partition_name);
+	if (key.plan_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.plan_name);
+
+	/*
+	 * hashfn_unstable.h recommends using string length as tweak. It's not
+	 * clear to me what to do if there are multiple strings, so for now I'm
+	 * just using the total of all of the lengths.
+	 */
+	return fasthash_final32(&hs, sp_len);
+}
+
+/*
+ * Look for matching entries.
+ */
+static Bitmapset *
+pgpa_trove_slice_lookup(pgpa_trove_slice *tslice, pgpa_identifier *rid)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	Bitmapset  *result = NULL;
+
+	Assert(rid->occurrence >= 1);
+
+	key.alias_name = rid->alias_name;
+	key.partition_name = rid->partrel;
+	key.plan_name = rid->plan_name;
+
+	element = pgpa_trove_entry_lookup(tslice->hash, key);
+
+	if (element != NULL)
+	{
+		int			i = -1;
+
+		while ((i = bms_next_member(element->indexes, i)) >= 0)
+		{
+			pgpa_trove_entry *entry = &tslice->entries[i];
+
+			/*
+			 * We know that this target or one of its descendents matches the
+			 * identifier on the three key fields above, but we don't know
+			 * which descendent or whether the occurence and schema also
+			 * match.
+			 */
+			if (pgpa_identifier_matches_target(rid, entry->target))
+				result = bms_add_member(result, i);
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
new file mode 100644
index 00000000000..479c3f75778
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -0,0 +1,113 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.h
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_TROVE_H
+#define PGPA_TROVE_H
+
+#include "pgpa_ast.h"
+
+#include "nodes/bitmapset.h"
+
+typedef struct pgpa_trove pgpa_trove;
+
+/*
+ * Flags that can be set on a pgpa_trove_entry to indicate what happened when
+ * trying to plan using advice.
+ *
+ * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_TE_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_TE_MATCH_PARTIAL		0x0001
+#define PGPA_TE_MATCH_FULL			0x0002
+#define PGPA_TE_INAPPLICABLE		0x0004
+#define PGPA_TE_CONFLICTING			0x0008
+#define PGPA_TE_FAILED				0x0010
+
+/*
+ * Each entry in a trove of advice represents the application of a tag to
+ * a single target.
+ */
+typedef struct pgpa_trove_entry
+{
+	pgpa_advice_tag_type tag;
+	pgpa_advice_target *target;
+	int			flags;
+} pgpa_trove_entry;
+
+/*
+ * What kind of information does the caller want to find in a trove?
+ *
+ * PGPA_TROVE_LOOKUP_SCAN means we're looking for scan advice.
+ *
+ * PGPA_TROVE_LOOKUP_JOIN means we're looking for join-related advice.
+ * This includes join order advice, join method advice, and semijoin-uniqueness
+ * advice.
+ *
+ * PGPA_TROVE_LOOKUP_REL means we're looking for general advice about this
+ * a RelOptInfo that may correspond to either a scan or a join. This includes
+ * gather-related advice and partitionwise advice. Note that partitionwise
+ * advice might seem like join advice, but that's not a helpful way of viewing
+ * the matter because (1) partitionwise advice is also relevant at the scan
+ * level and (2) other types of join advice affect only what to do from
+ * join_path_setup_hook, but partitionwise advice affects what to do in
+ * joinrel_setup_hook.
+ */
+typedef enum pgpa_trove_lookup_type
+{
+	PGPA_TROVE_LOOKUP_JOIN,
+	PGPA_TROVE_LOOKUP_REL,
+	PGPA_TROVE_LOOKUP_SCAN
+} pgpa_trove_lookup_type;
+
+/*
+ * This struct is used to store the result of a trove lookup. For each member
+ * of "indexes", the entry at the corresponding offset within "entries" is one
+ * of the results.
+ */
+typedef struct pgpa_trove_result
+{
+	pgpa_trove_entry *entries;
+	Bitmapset  *indexes;
+} pgpa_trove_result;
+
+extern pgpa_trove *pgpa_build_trove(List *advice_items);
+extern void pgpa_trove_lookup(pgpa_trove *trove,
+							  pgpa_trove_lookup_type type,
+							  int nrids,
+							  pgpa_identifier *rids,
+							  pgpa_trove_result *result);
+extern void pgpa_trove_lookup_all(pgpa_trove *trove,
+								  pgpa_trove_lookup_type type,
+								  pgpa_trove_entry **entries,
+								  int *nentries);
+extern char *pgpa_cstring_trove_entry(pgpa_trove_entry *entry);
+extern void pgpa_trove_set_flags(pgpa_trove_entry *entries,
+								 Bitmapset *indexes, int flags);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
new file mode 100644
index 00000000000..4dffc60114a
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -0,0 +1,965 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.c
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/plannodes.h"
+#include "parser/parsetree.h"
+
+static void pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+								  bool within_join_problem,
+								  pgpa_join_unroller *join_unroller,
+								  List *active_query_features,
+								  bool beneath_any_gather);
+static Bitmapset *pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+											 pgpa_unrolled_join *ujoin);
+
+static pgpa_query_feature *pgpa_add_feature(pgpa_plan_walker_context *walker,
+											pgpa_qf_type type,
+											Plan *plan);
+
+static void pgpa_qf_add_rti(List *active_query_features, Index rti);
+static void pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids);
+static void pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan,
+								  List *rtable);
+
+static bool pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+										   Index rtable_length,
+										   pgpa_identifier *rt_identifiers,
+										   pgpa_advice_target *target,
+										   bool toplevel);
+static bool pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+												  Index rtable_length,
+												  pgpa_identifier *rt_identifiers,
+												  pgpa_advice_target *target);
+static bool pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+									  pgpa_scan_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+										 pgpa_qf_type type,
+										 Bitmapset *relids);
+static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+									  pgpa_join_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+										   Bitmapset *relids);
+static Index pgpa_walker_get_rti(Index rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid);
+
+/*
+ * Top-level entrypoint for the plan tree walk.
+ *
+ * Populates walker based on a traversal of the Plan trees in pstmt.
+ *
+ * sj_unique_rels is a list of pgpa_sj_unique_rel objects, one for each
+ * relation we considered making unique as part of semijoin planning.
+ */
+void
+pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
+				 List *sj_unique_rels)
+{
+	ListCell   *lc;
+	List	   *sj_unique_rtis = NULL;
+	List	   *sj_nonunique_qfs = NULL;
+
+	/* Initialization. */
+	memset(walker, 0, sizeof(pgpa_plan_walker_context));
+	walker->pstmt = pstmt;
+
+	/* Walk the main plan tree. */
+	pgpa_walk_recursively(walker, pstmt->planTree, 0, NULL, NIL, false);
+
+	/* Main plan tree walk won't reach subplans, so walk those. */
+	foreach(lc, pstmt->subplans)
+	{
+		Plan	   *plan = lfirst(lc);
+
+		if (plan != NULL)
+			pgpa_walk_recursively(walker, plan, 0, NULL, NIL, false);
+	}
+
+	/* Adjust RTIs from sj_unique_rels for the flattened range table. */
+	foreach_ptr(pgpa_sj_unique_rel, ur, sj_unique_rels)
+	{
+		int			rtindex = -1;
+		int			rtoffset = 0;
+		bool		dummy = false;
+		Bitmapset  *relids = NULL;
+
+		/* If this is a subplan, find the range table offset. */
+		if (ur->plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				if (strcmp(ur->plan_name, rtinfo->plan_name) == 0)
+				{
+					rtoffset = rtinfo->rtoffset;
+					dummy = rtinfo->dummy;
+					break;
+				}
+			}
+
+			if (rtoffset == 0)
+				elog(ERROR, "no rtoffset for plan %s", ur->plan_name);
+		}
+
+		/* If this entry pertains to a dummy subquery, ignore it. */
+		if (dummy)
+			continue;
+
+		/* Offset each entry from the original set. */
+		while ((rtindex = bms_next_member(ur->relids, rtindex)) >= 0)
+			relids = bms_add_member(relids, rtindex + rtoffset);
+
+		/* Store the resulting set. */
+		sj_unique_rtis = lappend(sj_unique_rtis, relids);
+	}
+
+	/*
+	 * Remove any non-unique semjoin query features for which making the rel
+	 * unique wasn't considered.
+	 */
+	foreach_ptr(pgpa_query_feature, qf,
+				walker->query_features[PGPAQF_SEMIJOIN_NON_UNIQUE])
+	{
+		if (list_member(sj_unique_rtis, qf->relids))
+			sj_nonunique_qfs = lappend(sj_nonunique_qfs, qf);
+	}
+	walker->query_features[PGPAQF_SEMIJOIN_NON_UNIQUE] = sj_nonunique_qfs;
+
+	/*
+	 * If we find any cases where analysis of the Plan tree shows that the
+	 * semijoin was made unique but this possibility was never observed to be
+	 * considered during planning, then we have a bug somewhere.
+	 */
+	foreach_ptr(pgpa_query_feature, qf,
+				walker->query_features[PGPAQF_SEMIJOIN_UNIQUE])
+	{
+		if (!list_member(sj_unique_rtis, qf->relids))
+		{
+			StringInfoData buf;
+
+			initStringInfo(&buf);
+			outBitmapset(&buf, qf->relids);
+			elog(ERROR,
+				 "unique semijoin found for relids %s but not observed during planning",
+				 buf.data);
+		}
+	}
+}
+
+/*
+ * Main workhorse for the plan tree walk.
+ *
+ * If within_join_problem is true, we encountered a join at some higher level
+ * of the tree walk and haven't yet descended out of the portion of the plan
+ * tree that is part of that same join problem. We're no longer in the same
+ * join problem if (1) we cross into a different subquery or (2) we descend
+ * through an Append or MergeAppend node, below which any further joins would
+ * be partitionwise joins planned separately from the outer join problem.
+ *
+ * If join_unroller != NULL, the join unroller code expects us to find a join
+ * that should be unrolled into that object. This implies that we're within a
+ * join problem, but the reverse is not true: when we've traversed all the
+ * joins but are still looking for the scan that is the leaf of the join tree,
+ * join_unroller will be NULL but within_join_problem will be true.
+ *
+ * Each element of active_query_features corresponds to some item of advice
+ * that needs to enumerate all the relations it affects. We add RTIs we find
+ * during tree traversal to each of these query features.
+ *
+ * If beneath_any_gather == true, some higher level of the tree traversal found
+ * a Gather or Gather Merge node.
+ */
+static void
+pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+					  bool within_join_problem,
+					  pgpa_join_unroller *join_unroller,
+					  List *active_query_features,
+					  bool beneath_any_gather)
+{
+	pgpa_join_unroller *outer_join_unroller = NULL;
+	pgpa_join_unroller *inner_join_unroller = NULL;
+	bool		join_unroller_toplevel = false;
+	List	   *pushdown_query_features = NIL;
+	ListCell   *lc;
+	List	   *extraplans = NIL;
+	List	   *elided_nodes = NIL;
+
+	Assert(within_join_problem || join_unroller == NULL);
+
+	/*
+	 * If this is a Gather or Gather Merge node, directly add it to the list
+	 * of currently-active query features.
+	 *
+	 * Otherwise, check the future_query_features list to see whether this was
+	 * previously identified as a plan node that needs to be treated as a
+	 * query feature.
+	 *
+	 * Note that the caller also has a copy to active_query_features, so we
+	 * can't destructively modify it without making a copy.
+	 */
+	if (IsA(plan, Gather))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER, plan));
+		beneath_any_gather = true;
+	}
+	else if (IsA(plan, GatherMerge))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER_MERGE, plan));
+		beneath_any_gather = true;
+	}
+	else
+	{
+		foreach_ptr(pgpa_query_feature, qf, walker->future_query_features)
+		{
+			if (qf->plan == plan)
+			{
+				active_query_features = list_copy(active_query_features);
+				active_query_features = lappend(active_query_features, qf);
+				walker->future_query_features =
+					list_delete_ptr(walker->future_query_features, plan);
+				break;
+			}
+		}
+	}
+
+	/*
+	 * Find all elided nodes for this Plan node.
+	 */
+	foreach_node(ElidedNode, n, walker->pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_nodes = lappend(elided_nodes, n);
+	}
+
+	/* If we found any elided_nodes, handle them. */
+	if (elided_nodes != NIL)
+	{
+		int			num_elided_nodes = list_length(elided_nodes);
+		ElidedNode *last_elided_node;
+
+		/*
+		 * RTIs for the final -- and thus logically uppermost -- elided node
+		 * should be collected for query features passed down by the caller.
+		 * However, elided nodes act as barriers to query features, which
+		 * means that (1) the remaining elided nodes, if any, should be
+		 * ignored for purposes of query features and (2) the list of active
+		 * query features should be reset to empty so that we do not add RTIs
+		 * from the plan node that is logically beneath the elided node to the
+		 * query features passed down from the caller.
+		 */
+		last_elided_node = list_nth(elided_nodes, num_elided_nodes - 1);
+		pgpa_qf_add_rtis(active_query_features,
+						 pgpa_filter_out_join_relids(last_elided_node->relids,
+													 walker->pstmt->rtable));
+		active_query_features = NIL;
+
+		/*
+		 * If we're within a join problem, the join_unroller is responsible
+		 * for building the scan for the final elided node, so throw it out.
+		 */
+		if (within_join_problem)
+			elided_nodes = list_truncate(elided_nodes, num_elided_nodes - 1);
+
+		/* Build scans for all (or the remaining) elided nodes. */
+		foreach_node(ElidedNode, elided_node, elided_nodes)
+		{
+			(void) pgpa_build_scan(walker, plan, elided_node,
+								   beneath_any_gather, within_join_problem);
+		}
+
+		/*
+		 * If there were any elided nodes, then everything beneath those nodes
+		 * is not part of the same join problem.
+		 *
+		 * In more detail, if an Append or MergeAppend was elided, then a
+		 * partitionwise join was chosen and only a single child survived; if
+		 * a SubqueryScan was elided, the subquery was planned without
+		 * flattening it into the parent.
+		 */
+		within_join_problem = false;
+		join_unroller = NULL;
+	}
+
+	/*
+	 * If we're within a join problem, the join unroller is responsible for
+	 * building any required scan for this node. If not, we do it here.
+	 */
+	if (!within_join_problem)
+		(void) pgpa_build_scan(walker, plan, NULL, beneath_any_gather, false);
+
+	/*
+	 * If this join needs to unrolled but there's no join unroller already
+	 * available, create one.
+	 */
+	if (join_unroller == NULL && pgpa_is_join(plan))
+	{
+		join_unroller = pgpa_create_join_unroller();
+		join_unroller_toplevel = true;
+		within_join_problem = true;
+	}
+
+	/*
+	 * If this join is to be unrolled, pgpa_unroll_join() will return the join
+	 * unroller object that should be passed down when we recurse into the
+	 * outer and inner sides of the plan.
+	 */
+	if (join_unroller != NULL)
+		pgpa_unroll_join(walker, plan, beneath_any_gather, join_unroller,
+						 &outer_join_unroller, &inner_join_unroller);
+
+	/* Add RTIs from the plan node to all active query features. */
+	pgpa_qf_add_plan_rtis(active_query_features, plan, walker->pstmt->rtable);
+
+	/*
+	 * Recurse into the outer and inner subtrees.
+	 *
+	 * As an exception, if this is a ForeignScan, don't recurse. postgres_fdw
+	 * sometimes stores an EPQ recheck plan in plan->leftree, but that's going
+	 * to mention the same set of relations as the ForeignScan itself, and we
+	 * have no way to emit advice targeting the EPQ case vs. the non-EPQ case.
+	 * Moreover, it's not entirely clear what other FDWs might do with the
+	 * left and right subtrees. Maybe some better handling is needed here, but
+	 * for now, we just punt.
+	 */
+	if (!IsA(plan, ForeignScan))
+	{
+		if (plan->lefttree != NULL)
+			pgpa_walk_recursively(walker, plan->lefttree, within_join_problem,
+								  outer_join_unroller, active_query_features,
+								  beneath_any_gather);
+		if (plan->righttree != NULL)
+			pgpa_walk_recursively(walker, plan->righttree, within_join_problem,
+								  inner_join_unroller, active_query_features,
+								  beneath_any_gather);
+	}
+
+	/*
+	 * If we created a join unroller up above, then it's also our join to use
+	 * it to build the final pgpa_unrolled_join, and to destroy the object.
+	 */
+	if (join_unroller_toplevel)
+	{
+		pgpa_unrolled_join *ujoin;
+
+		ujoin = pgpa_build_unrolled_join(walker, join_unroller);
+		walker->toplevel_unrolled_joins =
+			lappend(walker->toplevel_unrolled_joins, ujoin);
+		pgpa_destroy_join_unroller(join_unroller);
+		(void) pgpa_process_unrolled_join(walker, ujoin);
+	}
+
+	/*
+	 * Some plan types can have additional children. Nodes like Append that
+	 * can have any number of children store them in a List; a SubqueryScan
+	 * just has a field for a single additional Plan.
+	 */
+	switch (nodeTag(plan))
+	{
+		case T_Append:
+			{
+				Append	   *aplan = (Append *) plan;
+
+				extraplans = aplan->appendplans;
+				if (bms_is_empty(aplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_MergeAppend:
+			{
+				MergeAppend *maplan = (MergeAppend *) plan;
+
+				extraplans = maplan->mergeplans;
+				if (bms_is_empty(maplan->apprelids))
+					pushdown_query_features = active_query_features;
+			}
+			break;
+		case T_BitmapAnd:
+			extraplans = ((BitmapAnd *) plan)->bitmapplans;
+			break;
+		case T_BitmapOr:
+			extraplans = ((BitmapOr *) plan)->bitmapplans;
+			break;
+		case T_SubqueryScan:
+
+			/*
+			 * We don't pass down active_query_features across here, because
+			 * those are specific to a subquery level.
+			 */
+			pgpa_walk_recursively(walker, ((SubqueryScan *) plan)->subplan,
+								  0, NULL, NIL, beneath_any_gather);
+			break;
+		case T_CustomScan:
+			extraplans = ((CustomScan *) plan)->custom_plans;
+			break;
+		default:
+			break;
+	}
+
+	/* If we found a list of extra children, iterate over it. */
+	foreach(lc, extraplans)
+	{
+		Plan	   *subplan = lfirst(lc);
+
+		pgpa_walk_recursively(walker, subplan, 0, NULL, pushdown_query_features,
+							  beneath_any_gather);
+	}
+}
+
+/*
+ * Perform final processing of a newly-constructed pgpa_unrolled_join. This
+ * only needs to be called for toplevel pgpa_unrolled_join objects, since it
+ * recurses to sub-joins as needed.
+ *
+ * Our goal is to add the set of inner relids to the relevant join_strategies
+ * list, and to do the same for any sub-joins. To that end, the return value
+ * is the set of relids found beneath the inner side of the join, but it is
+ * expected that the toplevel caller will ignore this.
+ */
+static Bitmapset *
+pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+						   pgpa_unrolled_join *ujoin)
+{
+	Bitmapset  *all_relids = NULL;
+
+	for (int k = 0; k < ujoin->ninner; ++k)
+	{
+		pgpa_join_member *member = &ujoin->inner[k];
+		Bitmapset  *relids;
+
+		if (member->unrolled_join != NULL)
+			relids = pgpa_process_unrolled_join(walker,
+												member->unrolled_join);
+		else
+		{
+			Assert(member->scan != NULL);
+			relids = member->scan->relids;
+		}
+		walker->join_strategies[ujoin->strategy[k]] =
+			lappend(walker->join_strategies[ujoin->strategy[k]], relids);
+		all_relids = bms_add_members(all_relids, relids);
+	}
+
+	return all_relids;
+}
+
+/*
+ * Arrange for the given plan node to be treated as a query feature when the
+ * tree walk reaches it.
+ *
+ * Make sure to only use this for nodes that the tree walk can't have reached
+ * yet!
+ */
+void
+pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+						pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = pgpa_add_feature(walker, type, plan);
+
+	walker->future_query_features =
+		lappend(walker->future_query_features, qf);
+}
+
+/*
+ * Return the last of any elided nodes associated with this plan node ID.
+ *
+ * The last elided node is the one that would have been uppermost in the plan
+ * tree had it not been removed during setrefs processig.
+ */
+ElidedNode *
+pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan)
+{
+	ElidedNode *elided_node = NULL;
+
+	foreach_node(ElidedNode, n, pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_node = n;
+	}
+
+	return elided_node;
+}
+
+/*
+ * Certain plan nodes can refer to a set of RTIs. Extract and return the set.
+ */
+Bitmapset *
+pgpa_relids(Plan *plan)
+{
+	if (IsA(plan, Result))
+		return ((Result *) plan)->relids;
+	else if (IsA(plan, ForeignScan))
+		return ((ForeignScan *) plan)->fs_relids;
+	else if (IsA(plan, Append))
+		return ((Append *) plan)->apprelids;
+	else if (IsA(plan, MergeAppend))
+		return ((MergeAppend *) plan)->apprelids;
+
+	return NULL;
+}
+
+/*
+ * Extract the scanned RTI from a plan node.
+ *
+ * Returns 0 if there isn't one.
+ */
+Index
+pgpa_scanrelid(Plan *plan)
+{
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+		case T_SubqueryScan:
+		case T_FunctionScan:
+		case T_TableFuncScan:
+		case T_ValuesScan:
+		case T_CteScan:
+		case T_NamedTuplestoreScan:
+		case T_WorkTableScan:
+		case T_ForeignScan:
+		case T_CustomScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+			return ((Scan *) plan)->scanrelid;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
+ */
+Bitmapset *
+pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	Bitmapset  *result = NULL;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind != RTE_JOIN)
+			result = bms_add_member(result, rti);
+	}
+
+	return result;
+}
+
+/*
+ * Create a pgpa_query_feature and add it to the list of all query features
+ * for this plan.
+ */
+static pgpa_query_feature *
+pgpa_add_feature(pgpa_plan_walker_context *walker,
+				 pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = palloc0_object(pgpa_query_feature);
+
+	qf->type = type;
+	qf->plan = plan;
+
+	walker->query_features[qf->type] =
+		lappend(walker->query_features[qf->type], qf);
+
+	return qf;
+}
+
+/*
+ * Add a single RTI to each active query feature.
+ */
+static void
+pgpa_qf_add_rti(List *active_query_features, Index rti)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_member(qf->relids, rti);
+	}
+}
+
+/*
+ * Add a set of RTIs to each active query feature.
+ */
+static void
+pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_members(qf->relids, relids);
+	}
+}
+
+/*
+ * Add RTIs directly contained in a plan node to each active query feature,
+ * but filter out any join RTIs, since advice doesn't mention those.
+ */
+static void
+pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan, List *rtable)
+{
+	Bitmapset  *relids;
+	Index		rti;
+
+	if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		relids = pgpa_filter_out_join_relids(relids, rtable);
+		pgpa_qf_add_rtis(active_query_features, relids);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+		pgpa_qf_add_rti(active_query_features, rti);
+}
+
+/*
+ * If we generated plan advice using the provided walker object and array
+ * of identifiers, would we generate the specified tag/target combination?
+ *
+ * If yes, the plan conforms to the advice; if no, it does not. Note that
+ * we have know way of knowing whether the planner was forced to emit a plan
+ * that conformed to the advice or just happened to do so.
+ */
+bool
+pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+						 pgpa_identifier *rt_identifiers,
+						 pgpa_advice_tag_type tag,
+						 pgpa_advice_target *target)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	Bitmapset  *relids = NULL;
+
+	if (tag == PGPA_TAG_JOIN_ORDER)
+	{
+		foreach_ptr(pgpa_unrolled_join, ujoin, walker->toplevel_unrolled_joins)
+		{
+			if (pgpa_walker_join_order_matches(ujoin, rtable_length,
+											   rt_identifiers, target, true))
+				return true;
+		}
+
+		return false;
+	}
+
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		Index		rti;
+
+		rti = pgpa_walker_get_rti(rtable_length, rt_identifiers, &target->rid);
+		relids = bms_make_singleton(rti);
+	}
+	else
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			Index		rti;
+
+			Assert(child_target->ttype == PGPA_TARGET_IDENTIFIER);
+			rti = pgpa_compute_rti_from_identifier(rtable_length,
+												   rt_identifiers,
+												   &child_target->rid);
+			if (rti == 0)
+				elog(ERROR, "cannot determine RTI for advice target");
+			relids = bms_add_member(relids, rti);
+		}
+	}
+
+	switch (tag)
+	{
+		case PGPA_TAG_JOIN_ORDER:
+			/* should have been handled above */
+			pg_unreachable();
+			break;
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_BITMAP_HEAP,
+											 relids);
+		case PGPA_TAG_FOREIGN_JOIN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_FOREIGN,
+											 relids);
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX_ONLY,
+											 relids);
+		case PGPA_TAG_INDEX_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_INDEX,
+											 relids);
+		case PGPA_TAG_PARTITIONWISE:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_PARTITIONWISE,
+											 relids);
+		case PGPA_TAG_SEQ_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_SEQ,
+											 relids);
+		case PGPA_TAG_TID_SCAN:
+			return pgpa_walker_contains_scan(walker,
+											 PGPA_SCAN_TID,
+											 relids);
+		case PGPA_TAG_GATHER:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER,
+												relids);
+		case PGPA_TAG_GATHER_MERGE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER_MERGE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_NON_UNIQUE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_UNIQUE,
+												relids);
+		case PGPA_TAG_HASH_JOIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_HASH_JOIN,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_PLAIN,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MEMOIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_PLAIN,
+											 relids);
+		case PGPA_TAG_NO_GATHER:
+			return pgpa_walker_contains_no_gather(walker, relids);
+	}
+
+	/* should not get here */
+	return false;
+}
+
+/*
+ * Does an unrolled join match the join order specified by an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+							   Index rtable_length,
+							   pgpa_identifier *rt_identifiers,
+							   pgpa_advice_target *target,
+							   bool toplevel)
+{
+	int			nchildren = list_length(target->children);
+
+	Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	/* At toplevel, we allow a prefix match. */
+	if (toplevel)
+	{
+		if (nchildren > ujoin->ninner + 1)
+			return false;
+	}
+	else
+	{
+		if (nchildren != ujoin->ninner + 1)
+			return false;
+	}
+
+	/* Outermost rel must match. */
+	if (!pgpa_walker_join_order_matches_member(&ujoin->outer,
+											   rtable_length,
+											   rt_identifiers,
+											   linitial(target->children)))
+		return false;
+
+	/* Each inner rel must match. */
+	for (int n = 0; n < nchildren - 1; ++n)
+	{
+		pgpa_advice_target *child_target = list_nth(target->children, n + 1);
+
+		if (!pgpa_walker_join_order_matches_member(&ujoin->inner[n],
+												   rtable_length,
+												   rt_identifiers,
+												   child_target))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Does one member of an unrolled join match an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+									  Index rtable_length,
+									  pgpa_identifier *rt_identifiers,
+									  pgpa_advice_target *target)
+{
+	Bitmapset  *relids = NULL;
+
+	if (member->unrolled_join != NULL)
+	{
+		if (target->ttype != PGPA_TARGET_ORDERED_LIST)
+			return false;
+		return pgpa_walker_join_order_matches(member->unrolled_join,
+											  rtable_length,
+											  rt_identifiers,
+											  target,
+											  false);
+	}
+
+	Assert(member->scan != NULL);
+	switch (target->ttype)
+	{
+		case PGPA_TARGET_ORDERED_LIST:
+			/* Could only match an unrolled join */
+			return false;
+
+		case PGPA_TARGET_UNORDERED_LIST:
+			{
+				foreach_ptr(pgpa_advice_target, child_target, target->children)
+				{
+					Index		rti;
+
+					rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+											  &child_target->rid);
+					relids = bms_add_member(relids, rti);
+				}
+				break;
+			}
+
+		case PGPA_TARGET_IDENTIFIER:
+			{
+				Index		rti;
+
+				rti = pgpa_walker_get_rti(rtable_length, rt_identifiers,
+										  &target->rid);
+				relids = bms_make_singleton(rti);
+				break;
+			}
+	}
+
+	return bms_equal(member->scan->relids, relids);
+}
+
+/*
+ * Does this walker say that the given scan strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_scan(pgpa_plan_walker_context *walker,
+						  pgpa_scan_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *scans = walker->scans[strategy];
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		/*
+		 * XXX. If this is index-related advice, we should also validate that
+		 * the advice target's index target matches the Plan tree.
+		 */
+		if (bms_equal(scan->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does this walker say that the given query feature applies to the given
+ * relid set?
+ */
+static bool
+pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+							 pgpa_qf_type type,
+							 Bitmapset *relids)
+{
+	List	   *query_features = walker->query_features[type];
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (bms_equal(qf->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given join strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+						  pgpa_join_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *join_strategies = walker->join_strategies[strategy];
+
+	foreach_ptr(Bitmapset, jsrelids, join_strategies)
+	{
+		if (bms_equal(jsrelids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given relids should be marked as NO_GATHER?
+ */
+static bool
+pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+							   Bitmapset *relids)
+{
+	return bms_is_subset(relids, walker->no_gather_scans);
+}
+
+/*
+ * Convenience function to convert a relation identifier to an RTI.
+ *
+ * We throw an error here because we expect this to be used on system-generated
+ * advice. Hence, failure here indicates an advice generation bug.
+ */
+static Index
+pgpa_walker_get_rti(Index rtable_length,
+					pgpa_identifier *rt_identifiers,
+					pgpa_identifier *rid)
+{
+	Index		rti;
+
+	rti = pgpa_compute_rti_from_identifier(rtable_length,
+										   rt_identifiers,
+										   rid);
+	if (rti == 0)
+		elog(ERROR, "cannot determine RTI for advice target");
+	return rti;
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
new file mode 100644
index 00000000000..b91a36ca3dd
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -0,0 +1,141 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.h
+ *	  Plan tree iteration
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_WALKER_H
+#define PGPA_WALKER_H
+
+#include "pgpa_ast.h"
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+
+/*
+ * When generating advice, we should emit either SEMIJOIN_UNIQUE advice or
+ * SEMIJOIN_NON_UNIQUE advice for each semijoin depending on whether we chose
+ * to implement it as a semijoin or whether we instead chose to make the
+ * nullable side unique and then perform an inner join. When the make-unique
+ * strategy is not chosen, it's not easy to tell from the final plan tree
+ * whether it was considered. That's awkward, because we don't want to emit
+ * useless SEMIJOIN_NON_UNIQUE advice when there was no decision to be made.
+ *
+ * To avoid that, during planning, we create a pgpa_sj_unique_rel for each
+ * relation that we considered making unique for purposes of semijoin planning.
+ */
+typedef struct pgpa_sj_unique_rel
+{
+	char	   *plan_name;
+	Bitmapset  *relids;
+} pgpa_sj_unique_rel;
+
+/*
+ * We use the term "query feature" to refer to plan nodes that are interesting
+ * in the following way: to generate advice, we'll need to know the set of
+ * same-subquery, non-join RTIs occuring at or below that plan node, without
+ * admixture of parent and child RTIs.
+ *
+ * For example, Gather nodes, desiginated by PGPAQF_GATHER, and Gather Merge
+ * nodes, designated by PGPAQF_GATHER_MERGE, are query features, because we'll
+ * want to admit some kind of advice that describes the portion of the plan
+ * tree that appears beneath those nodes.
+ *
+ * Each semijoin can be implemented either by directly performing a semijoin,
+ * or by making one side unique and then performing a normal join. Either way,
+ * we use a query feature to notice what decision was made, so that we can
+ * describe it by enumerating the RTIs on that side of the join.
+ *
+ * To elaborate on the "no admixture of parent and child RTIs" rule, in all of
+ * these cases, if the entirety of an inheritance hierarchy appears beneath
+ * the query feature, we only want to name the parent table. But it's also
+ * possible to have cases where we must name child tables. This is particularly
+ * likely to happen when partitionwise join is in use, but could happen for
+ * Gather or Gather Merge even without that, if one of those appears below
+ * an Append or MergeAppend node for a single table.
+ */
+typedef enum pgpa_qf_type
+{
+	PGPAQF_GATHER,
+	PGPAQF_GATHER_MERGE,
+	PGPAQF_SEMIJOIN_NON_UNIQUE,
+	PGPAQF_SEMIJOIN_UNIQUE
+	/* update NUM_PGPA_QF_TYPES if you add anything here */
+} pgpa_qf_type;
+
+#define NUM_PGPA_QF_TYPES ((int) PGPAQF_SEMIJOIN_UNIQUE + 1)
+
+/*
+ * For each query feature, we keep track of the feature type and the set of
+ * relids that we found underneath the relevant plan node. See the comments
+ * on pgpa_qf_type, above, for additional details.
+ */
+typedef struct pgpa_query_feature
+{
+	pgpa_qf_type type;
+	Plan	   *plan;
+	Bitmapset  *relids;
+} pgpa_query_feature;
+
+/*
+ * Context object for plan tree walk.
+ *
+ * pstmt is the PlannedStmt we're studying.
+ *
+ * scans is an array of lists of pgpa_scan objects. The array is indexed by
+ * the scan's pgpa_scan_strategy.
+ *
+ * no_gather_scans is the set of scan RTIs that do not appear beneath any
+ * Gather or Gather Merge node.
+ *
+ * toplevel_unrolled_joins is a list of all pgpa_unrolled_join objects that
+ * are not a child of some other pgpa_unrolled_join.
+ *
+ * join_strategy is an array of lists of Bitmapset objects. Each Bitmapset
+ * is the set of relids that appears on the inner side of some join (excluding
+ * RTIs from partition children and subqueries). The array is indexed by
+ * pgpa_join_strategy.
+ *
+ * query_features is an array lists of pgpa_query_feature objects, indexed
+ * by pgpa_qf_type.
+ *
+ * future_query_features is only used during the plan tree walk and should
+ * be empty when the tree walk concludes. It is a list of pgpa_query_feature
+ * objects for Plan nodes that the plan tree walk has not yet encountered;
+ * when encountered, they will be moved to the list of active query features
+ * that is propagated via the call stack.
+ */
+typedef struct pgpa_plan_walker_context
+{
+	PlannedStmt *pstmt;
+	List	   *scans[NUM_PGPA_SCAN_STRATEGY];
+	Bitmapset  *no_gather_scans;
+	List	   *toplevel_unrolled_joins;
+	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
+	List	   *query_features[NUM_PGPA_QF_TYPES];
+	List	   *future_query_features;
+} pgpa_plan_walker_context;
+
+extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
+							 PlannedStmt *pstmt,
+							 List *sj_unique_rels);
+
+extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+									pgpa_qf_type type,
+									Plan *plan);
+
+extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
+extern Bitmapset *pgpa_relids(Plan *plan);
+extern Index pgpa_scanrelid(Plan *plan);
+extern Bitmapset *pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable);
+
+extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+									 pgpa_identifier *rt_identifiers,
+									 pgpa_advice_tag_type tag,
+									 pgpa_advice_target *target);
+
+#endif
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
new file mode 100644
index 00000000000..cb04ed5cf30
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -0,0 +1,79 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((d d/d.d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_order.sql b/contrib/pg_plan_advice/sql/join_order.sql
new file mode 100644
index 00000000000..5aa2fc62d34
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_order.sql
@@ -0,0 +1,96 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
+
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
diff --git a/contrib/pg_plan_advice/sql/join_strategy.sql b/contrib/pg_plan_advice/sql/join_strategy.sql
new file mode 100644
index 00000000000..8eb823f1c0e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_strategy.sql
@@ -0,0 +1,76 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/local_collector.sql b/contrib/pg_plan_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..fc838b2204d
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/local_collector.sql
@@ -0,0 +1,41 @@
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_plan_advice/sql/partitionwise.sql b/contrib/pg_plan_advice/sql/partitionwise.sql
new file mode 100644
index 00000000000..e42c0611760
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/partitionwise.sql
@@ -0,0 +1,78 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
new file mode 100644
index 00000000000..25416a75f46
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -0,0 +1,195 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+COMMIT;
+
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+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 > 0;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+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;
+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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+COMMIT;
+
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/syntax.sql b/contrib/pg_plan_advice/sql/syntax.sql
new file mode 100644
index 00000000000..0692dc895ca
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/syntax.sql
@@ -0,0 +1,57 @@
+LOAD 'pg_plan_advice';
+
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+EXPLAIN SELECT 1;
+
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+EXPLAIN SELECT 1;
+
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+SET pg_plan_advice.advice = 'SEQ_SCAN("")';
+SET pg_plan_advice.advice = 'SEQ_SCAN("a"';
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+SET pg_plan_advice.advice = '()';
+SET pg_plan_advice.advice = '123';
+
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+EXPLAIN SELECT 1;
+
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+EXPLAIN SELECT 1;
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
diff --git a/contrib/pg_plan_advice/t/001_regress.pl b/contrib/pg_plan_advice/t/001_regress.pl
new file mode 100644
index 00000000000..5f7584c3a02
--- /dev/null
+++ b/contrib/pg_plan_advice/t/001_regress.pl
@@ -0,0 +1,147 @@
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_plan_advice to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+#
+# We run with pg_plan_advice.shared_collection_limit set to ensure that the
+# plan tree walker code runs against every query in the regression tests. If
+# we're unable to properly analyze any of those plan trees, this test should fail.
+#
+# We set pg_plan_advice.advice to an advice string that will cause the advice
+# trove to be populated with a few entries of various sorts, but which we do
+# not expect to match anything in the regression test queries. This way, the
+# planner hooks will be called, improving code coverage, but no plans should
+# actually change.
+#
+# pg_plan_advice.always_explain_supplied_advice=false is needed to avoid breaking
+# regression test queries that use EXPLAIN. In the real world, it seems like
+# users will want EXPLAIN output to show supplied advice so that it's clear
+# whether normal planner behavior has been altered, but here that's undesirable.
+$node->append_conf('postgresql.conf', <<EOM);
+pg_plan_advice.shared_collection_limit=1000000
+shared_preload_libraries=pg_plan_advice
+pg_plan_advice.advice='SEQ_SCAN(entirely_fictitious) HASH_JOIN(total_fabrication) GATHER(completely_imaginary)'
+pg_plan_advice.always_explain_supplied_advice=false
+EOM
+$node->start;
+
+my $srcdir = abs_path("../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+# Create the extension so we can access the collector
+$node->safe_psql('postgres', 'CREATE EXTENSION pg_plan_advice');
+
+# Verify that a large amount of advice was collected
+my $all_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice();
+EOM
+cmp_ok($all_query_count, '>', 20000, "copious advice collected");
+
+# Verify that lots of different advice strings were collected
+my $distinct_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM
+	(SELECT DISTINCT advice FROM pg_get_collected_shared_advice());
+EOM
+cmp_ok($distinct_query_count, '>', 3000, "diverse advice collected");
+
+# We want to test for the presence of our known tags in the collected advice.
+# Put all tags into the hash that follows; map any tags that aren't tested
+# by the core regression tests to 0, and others to 1.
+my %tag_map = (
+	BITMAP_HEAP_SCAN => 1,
+	FOREIGN_JOIN => 0,
+	GATHER => 1,
+	GATHER_MERGE => 1,
+	HASH_JOIN => 1,
+	INDEX_ONLY_SCAN => 1,
+	INDEX_SCAN => 1,
+	JOIN_ORDER => 1,
+	MERGE_JOIN_MATERIALIZE => 1,
+	MERGE_JOIN_PLAIN => 1,
+	NESTED_LOOP_MATERIALIZE => 1,
+	NESTED_LOOP_MEMOIZE => 1,
+	NESTED_LOOP_PLAIN => 1,
+	NO_GATHER => 1,
+	PARTITIONWISE => 1,
+	SEMIJOIN_NON_UNIQUE => 1,
+	SEMIJOIN_UNIQUE => 1,
+	SEQ_SCAN => 1,
+	TID_SCAN => 1,
+);
+for my $tag (sort keys %tag_map)
+{
+	my $checkit = $tag_map{$tag};
+
+	# Search for the given tag. This is not entirely robust: it could get thrown
+	# off by a table alias such as "FOREIGN_JOIN(", but that probably won't
+	# happen in the core regression tests.
+	my $tag_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice()
+	WHERE advice LIKE '%$tag(%'
+EOM
+
+	# Check that the tag got a non-trivial amount of use, unless told otherwise.
+	cmp_ok($tag_count, '>', 10, "multiple uses of $tag") if $checkit;
+
+	# Regardless, note the exact count in the log, for human consumption.
+	note("found $tag_count advice strings containing $tag");
+}
+
+# Trigger a partial cleanup of the shared advice collector, and then a full
+# cleanup.
+$node->safe_psql('postgres', <<EOM);
+SET pg_plan_advice.shared_collection_limit=500;
+SELECT * FROM pg_clear_collected_shared_advice();
+EOM
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 58c9a3f1e01..47cdb4a7ada 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3964,6 +3964,44 @@ pg_uuid_t
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgpa_collected_advice
+pgpa_advice_item
+pgpa_advice_tag_type
+pgpa_advice_target
+pgpa_identifier
+pgpa_index_target
+pgpa_index_type
+pgpa_itm_type
+pgpa_join_class
+pgpa_join_member
+pgpa_join_state
+pgpa_join_strategy
+pgpa_join_unroller
+pgpa_local_advice
+pgpa_local_advice_chunk
+pgpa_output_context
+pgpa_plan_walker_context
+pgpa_planner_state
+pgpa_qf_type
+pgpa_query_feature
+pgpa_ri_checker
+pgpa_ri_checker_key
+pgpa_scan
+pgpa_scan_strategy
+pgpa_shared_advice
+pgpa_shared_advice_chunk
+pgpa_shared_state
+pgpa_sj_unique_rel
+pgpa_target_type
+pgpa_trove
+pgpa_trove_entry
+pgpa_trove_entry_element
+pgpa_trove_entry_hash
+pgpa_trove_entry_key
+pgpa_trove_lookup_type
+pgpa_trove_result
+pgpa_trove_slice
+pgpa_unrolled_join
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-17 10:12  Jakub Wartak <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 2 replies; 133+ messages in thread

From: Jakub Wartak @ 2025-12-17 10:12 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Dec 15, 2025 at 9:06 PM Robert Haas <[email protected]> wrote:
>
> Here's v7.
[..]

OK, so I've tested today from Your's branch directly, so I hope that
was also v7. Given the following q20 query:

SELECT s_name, s_address
FROM supplier, nation
WHERE s_suppkey in
    (SELECT ps_suppkey
     FROM partsupp
     WHERE ps_partkey in
         (SELECT p_partkey
          FROM part
          WHERE p_name LIKE 'forest%' )
       AND ps_availqty >
         (SELECT 0.5 * sum(l_quantity)
          FROM lineitem
          WHERE l_partkey = ps_partkey
            AND l_suppkey = ps_suppkey
            AND l_shipdate >= DATE '1994-01-01'
            AND l_shipdate < DATE '1994-01-01' + INTERVAL '1' year ) )
  AND s_nationkey = n_nationkey
  AND n_name = 'CANADA'
ORDER BY s_name;

in normal conditions (w/o advice) the above query generates:

 Sort  (cost=1010985030.44..1010985030.59 rows=61 width=51)
   Sort Key: supplier.s_name
   ->  Nested Loop  (cost=0.42..1010985028.63 rows=61 width=51)
         Join Filter: (nation.n_nationkey = supplier.s_nationkey)
         ->  Seq Scan on nation  (cost=0.00..1.31 rows=1 width=4)
               Filter: (n_name = 'CANADA'::bpchar)
         ->  Nested Loop Semi Join  (cost=0.42..1010985008.29
rows=1522 width=55)
               Join Filter: (partsupp.ps_suppkey = supplier.s_suppkey)
               ->  Seq Scan on supplier  (cost=0.00..249.30 rows=7730 width=59)
               ->  Materialize  (cost=0.42..1010755994.57 rows=1973 width=4)
                     ->  Nested Loop  (cost=0.42..1010755984.71
rows=1973 width=4)
                           ->  Seq Scan on part  (cost=0.00..4842.25
rows=1469 width=4)
                                 Filter: ((p_name)::text ~~ 'forest%'::text)
                           ->  Index Scan using pk_partsupp on
partsupp  (cost=0.42..688053.87 rows=1 width=8)
                                 Index Cond: (ps_partkey = part.p_partkey)
                                 Filter: ((ps_availqty)::numeric >
(SubPlan expr_1))
                                 SubPlan expr_1
                                   ->  Aggregate
(cost=172009.42..172009.44 rows=1 width=32)
                                         ->  Seq Scan on lineitem
(cost=0.00..172009.42 rows=1 width=5)
                                               Filter: ((l_shipdate >=
'1994-01-01'::date) AND (l_shipdate < '1995-01-01 00:00:00'::timestamp
without time zone) AND (l_partkey = partsupp.ps_partkey) AND
(l_suppkey = partsupp.ps_suppkey))


 Generated Plan Advice:
   JOIN_ORDER(nation (supplier (part partsupp)))
   NESTED_LOOP_PLAIN(partsupp partsupp) <--- [X]
   NESTED_LOOP_MATERIALIZE(partsupp)
   SEQ_SCAN(nation supplier part lineitem@expr_1)
   INDEX_SCAN(partsupp public.pk_partsupp)
   SEMIJOIN_NON_UNIQUE((partsupp part))
   NO_GATHER(supplier nation partsupp part lineitem@expr_1)

Please see the - I think it's confusing? -
NESTED_LOOP_MATERIALIZE(partsupp partsupp) - that's 2x the same
string? This causes it to turn into below plan -- I've marked the
problem with [X]

 Sort  (cost=50035755.50..50035755.66 rows=61 width=51)
   Sort Key: supplier.s_name
   ->  Nested Loop  (cost=12562154.32..50035753.70 rows=61 width=51)
         Join Filter: (nation.n_nationkey = supplier.s_nationkey)
         ->  Seq Scan on nation  (cost=0.00..1.31 rows=1 width=4)
               Filter: (n_name = 'CANADA'::bpchar)
         ->  Nested Loop Semi Join  (cost=12562154.32..50035733.36
rows=1522 width=55)
             [X] -- missing Join Filter here
               ->  Seq Scan on supplier  (cost=0.00..249.30 rows=7730 width=59)
               [X] -- HJ instead of Materialize+Nested Loop below:
               ->  Hash Join  (cost=12562154.32..12567002.09 rows=1 width=4)
                     Hash Cond: (part.p_partkey = partsupp.ps_partkey)
                     ->  Seq Scan on part  (cost=0.00..4842.25
rows=1469 width=4)
                           Filter: ((p_name)::text ~~ 'forest%'::text)
                     ->  Hash  (cost=12562154.02..12562154.02 rows=24 width=8)
                           ->  Index Scan using pk_partsupp on
partsupp  (cost=0.42..12562154.02 rows=24 width=8)
                                 [X] -- wrong Index Cond below
(suppkey instead of partkey)
                                 Index Cond: (ps_suppkey = supplier.s_suppkey)
                                 Filter: ((ps_availqty)::numeric >
(SubPlan expr_1))
                                 SubPlan expr_1
                                   ->  Aggregate
(cost=172009.42..172009.44 rows=1 width=32)
                                         ->  Seq Scan on lineitem
(cost=0.00..172009.42 rows=1 width=5)
                                               Filter: ((l_shipdate >=
'1994-01-01'::date) AND (l_shipdate < '1995-01-01 00:00:00'::timestamp
without time zone) AND (l_partkey = partsupp.ps_partkey) AND
(l_suppkey = partsupp.ps_suppkey))

Supplied Plan Advice:
   SEQ_SCAN(nation) /* matched */
   SEQ_SCAN(supplier) /* matched */
   SEQ_SCAN(part) /* matched */
   SEQ_SCAN(lineitem@expr_1) /* matched */
   INDEX_SCAN(partsupp public.pk_partsupp) /* matched */
   JOIN_ORDER(nation (supplier (part partsupp))) /* matched, conflicting */
   NESTED_LOOP_PLAIN(partsupp) /* matched, conflicting */
   NESTED_LOOP_PLAIN(partsupp) /* matched, conflicting */
   NESTED_LOOP_MATERIALIZE(partsupp) /* matched, conflicting, failed */
   SEMIJOIN_NON_UNIQUE((partsupp part)) /* matched, conflicting */
   NO_GATHER(supplier) /* matched */
   NO_GATHER(nation) /* matched */
   NO_GATHER(partsupp) /* matched */
   NO_GATHER(part) /* matched */
   NO_GATHER(lineitem@expr_1) /* matched */

So the difference is basically between:
    set pg_plan_advice.advice = '[..] NESTED_LOOP_PLAIN(partsupp
partsupp) NESTED_LOOP_MATERIALIZE(partsupp) [..]';
which causes wrong plan and outcome:
    NESTED_LOOP_MATERIALIZE(partsupp) /* matched, conflicting, failed */

and apparently proper advice like below which has better yield:
    set pg_plan_advice.advice = '[..] NESTED_LOOP_PLAIN(part partsupp)
NESTED_LOOP_MATERIALIZE(partsupp) [..]';
which is not generated , but caused good plan, however it also prints:
   NESTED_LOOP_PLAIN(part) /* matched, conflicting, failed */
   NESTED_LOOP_MATERIALIZE(partsupp) /* matched, conflicting */
but that seems "failed" there, seems to be untrue?

Another idea is perhaps, we could have some elog(WARNING) - but not
Asserts() - in assert-only enabled build that could alert us in case
of duplicated entries being detected for the same ops in
pg_plan_advice_explain_feedback()?

-J.





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-17 13:44  Jakub Wartak <[email protected]>
  parent: Jakub Wartak <[email protected]>
  1 sibling, 2 replies; 133+ messages in thread

From: Jakub Wartak @ 2025-12-17 13:44 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Dec 17, 2025 at 11:12 AM Jakub Wartak
<[email protected]> wrote:
>
> On Mon, Dec 15, 2025 at 9:06 PM Robert Haas <[email protected]> wrote:
> >
> > Here's v7.
> [..]
>[..q20..]

OK, now for the q10:

 Sort
   Sort Key: (sum((lineitem.l_extendedprice * ('1'::numeric -
lineitem.l_discount)))) DESC
   ->  Finalize GroupAggregate
         Group Key: customer.c_custkey, nation.n_name
         ->  Gather Merge
               Workers Planned: 2
               ->  Partial GroupAggregate
                     Group Key: customer.c_custkey, nation.n_name
                     ->  Sort
                           Sort Key: customer.c_custkey, nation.n_name
                           ->  Hash Join
                                 Hash Cond: (customer.c_nationkey =
nation.n_nationkey)
                                 ->  Parallel Hash Join
                                       Hash Cond: (orders.o_custkey =
customer.c_custkey)
                                       ->  Nested Loop
                                             ->  Parallel Seq Scan on orders
                                                   Filter:
((o_orderdate >= '1993-10-01'::date) AND (o_orderdate < '1994-01-01
00:00:00'::timestamp without time zone))
                                             ->  Index Scan using
lineitem_l_orderkey_idx_l_returnflag on lineitem
                                                   Index Cond:
(l_orderkey = orders.o_orderkey)
                                       ->  Parallel Hash
                                             ->  Parallel Seq Scan on customer
                                 ->  Hash
                                       ->  Seq Scan on nation
 Generated Plan Advice:
   JOIN_ORDER(orders lineitem customer nation)
   NESTED_LOOP_PLAIN(lineitem)
   HASH_JOIN(customer nation)
   SEQ_SCAN(orders customer nation)
   INDEX_SCAN(lineitem public.lineitem_l_orderkey_idx_l_returnflag)
   GATHER_MERGE((customer orders lineitem nation))

but when set the advice it generates wrong NL instead of expected
Parallel HJ (so another way to fix is to simply disable PQ, yuck),
but:

 Sort
   Sort Key: (sum((lineitem.l_extendedprice * ('1'::numeric -
lineitem.l_discount)))) DESC
   ->  Finalize GroupAggregate
         Group Key: customer.c_custkey, nation.n_name
         ->  Gather Merge
               Workers Planned: 2
               ->  Partial GroupAggregate
                     Group Key: customer.c_custkey, nation.n_name
                     ->  Sort
                           Sort Key: customer.c_custkey, nation.n_name
                           ->  Nested Loop
                                 ->  Hash Join
                                       Hash Cond:
(customer.c_nationkey = nation.n_nationkey)
                                       ->  Parallel Hash Join
                                             Hash Cond:
(orders.o_custkey = customer.c_custkey)
                                             ->  Parallel Seq Scan on orders
                                                   Filter:
((o_orderdate >= '1993-10-01'::date) AND (o_orderdate < '1994-01-01
00:00:00'::timestamp without time zone))
                                             ->  Parallel Hash
                                                   ->  Parallel Seq
Scan on customer
                                       ->  Hash
                                             ->  Seq Scan on nation
                                 ->  Index Scan using
lineitem_l_orderkey_idx_l_returnflag on lineitem
                                       Index Cond: (l_orderkey =
orders.o_orderkey)
 Supplied Plan Advice:
   SEQ_SCAN(orders) /* matched */
   SEQ_SCAN(customer) /* matched */
   SEQ_SCAN(nation) /* matched */
   INDEX_SCAN(lineitem public.lineitem_l_orderkey_idx_l_returnflag) /*
matched */
   JOIN_ORDER(orders lineitem customer nation) /* matched,
conflicting, failed */
   NESTED_LOOP_PLAIN(lineitem) /* matched, conflicting */
   HASH_JOIN(customer) /* matched, conflicting */
   HASH_JOIN(nation) /* matched, conflicting */
   GATHER_MERGE((customer orders lineitem nation)) /* matched */

So to me it looks like in Generated Plan Advice we:
- have proper HASH_JOIN(customer nation)
- but it somehow forgot to include "HASH_JOIN(orders)" to cover for
that Parallel Hash Join on (orders.o_custkey = customer.c_custkey)
with input from NL. After adding that manually, it achieves the same
input plan properly.

Please let me know if I'm wrong, I was kind of thinking Parallel is
not fully supported, but README/tests seem to state otherwise.

-J.





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-18 12:27  Jakub Wartak <[email protected]>
  parent: Jakub Wartak <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Jakub Wartak @ 2025-12-18 12:27 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Dec 17, 2025 at 2:44 PM Jakub Wartak
<[email protected]> wrote:
>
> On Wed, Dec 17, 2025 at 11:12 AM Jakub Wartak
> <[email protected]> wrote:
> >
> > On Mon, Dec 15, 2025 at 9:06 PM Robert Haas <[email protected]> wrote:
> > >
> > > Here's v7.
> > [..]
> >[..q20..]
>
> OK, now for the q10:

Hi, this is a follow-up just to the q10.

> So to me it looks like in Generated Plan Advice we:
> - have proper HASH_JOIN(customer nation)
> - but it somehow forgot to include "HASH_JOIN(orders)" to cover for
> that Parallel Hash Join on (orders.o_custkey = customer.c_custkey)
> with input from NL. After adding that manually, it achieves the same
> input plan properly.
[..]

Well, it's quite a ride with the Q10 and I partially wrong with above:

0. The reported earlier wrong missing "HASH_JOIN(orders customer)" -
that part was okay
1. The Incremental Sort is being used in the original plan, but is
still IS not reflected in the generated advice.
2a. I've noticed Memoize/Index Scan was not being respected for "nation"
2b. Seq scan for nation was being done for "nation"

So total modification list, I've ended up doing (+ for adding , - for removing):

+ HASH_JOIN(orders customer) -- from earlier reply
+ NESTED_LOOP_MEMOIZE(nation)
+ INDEX_SCAN(nation public.pk_nation)
- HASH_JOIN(customer nation) -- as it was we were having NL() in org plan
SEQ_SCAN(orders customer nation) ==> SEQ_SCAN(orders customer)

In full the best shape seems to be Q10 with pg_plan_advice.advice =
'HASH_JOIN(orders customer) JOIN_ORDER(orders lineitem customer
nation)    NESTED_LOOP_PLAIN(lineitem)    SEQ_SCAN(orders customer)
INDEX_SCAN(lineitem public.lineitem_l_orderkey_idx_l_returnflag)
GATHER_MERGE((customer orders lineitem nation))
NESTED_LOOP_MEMOIZE(nation)';

which yields:
 Sort
   Sort Key: (sum((lineitem.l_extendedprice * ('1'::numeric -
lineitem.l_discount)))) DESC
   ->  GroupAggregate
         Group Key: customer.c_custkey, nation.n_name
         ->  Gather Merge
               Workers Planned: 2
               ->  Sort
                     Sort Key: customer.c_custkey, nation.n_name
                     ->  Nested Loop
                           ->  Parallel Hash Join
                                 Hash Cond: (orders.o_custkey =
customer.c_custkey)
                                 ->  Nested Loop
                                       ->  Parallel Seq Scan on orders
                                             Filter: ((o_orderdate >=
'1993-10-01'::date) AND (o_orderdate < '1994-01-01
00:00:00'::timestamp without time zone))
                                       ->  Index Scan using
lineitem_l_orderkey_idx_l_returnflag on lineitem
                                             Index Cond: (l_orderkey =
orders.o_orderkey)
                                 ->  Parallel Hash
                                       ->  Parallel Seq Scan on customer
                           ->  Memoize
                                 Cache Key: customer.c_nationkey
                                 Cache Mode: logical
                                 ->  Index Scan using pk_nation on nation
                                       Index Cond: (n_nationkey =
customer.c_nationkey)

but that Incremental Sort *is* still missing. In original plan we are doing
   Incremental Sort (Sort Key: customer.c_custkey, nation.n_name,
Presorted Key: customer.c_custkey)
   <-- .... Sort(Sort Key: customer.c_custkey)

However, even with my overrides I haven't found an immediately obvious
way to force it to use Incremental Sort on a specific field, so it
just sorts on two at once. Maybe it's something that should be
expressed through GATHER_MERGE()?, but that's not obvious how and
where. In terms of raw performance , it seems to be very similiar
(98ms +/- 8ms even between those two).

-J.





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-18 13:36  Robert Haas <[email protected]>
  parent: Jakub Wartak <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Robert Haas @ 2025-12-18 13:36 UTC (permalink / raw)
  To: Jakub Wartak <[email protected]>; +Cc: Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Dec 17, 2025 at 5:12 AM Jakub Wartak
<[email protected]> wrote:
>  Sort  (cost=1010985030.44..1010985030.59 rows=61 width=51)
>    Sort Key: supplier.s_name
>    ->  Nested Loop  (cost=0.42..1010985028.63 rows=61 width=51)
>          Join Filter: (nation.n_nationkey = supplier.s_nationkey)
>          ->  Seq Scan on nation  (cost=0.00..1.31 rows=1 width=4)
>                Filter: (n_name = 'CANADA'::bpchar)
>          ->  Nested Loop Semi Join  (cost=0.42..1010985008.29
> rows=1522 width=55)
>                Join Filter: (partsupp.ps_suppkey = supplier.s_suppkey)
>                ->  Seq Scan on supplier  (cost=0.00..249.30 rows=7730 width=59)
>                ->  Materialize  (cost=0.42..1010755994.57 rows=1973 width=4)
>                      ->  Nested Loop  (cost=0.42..1010755984.71
> rows=1973 width=4)
>                            ->  Seq Scan on part  (cost=0.00..4842.25
> rows=1469 width=4)
>                                  Filter: ((p_name)::text ~~ 'forest%'::text)
>                            ->  Index Scan using pk_partsupp on
> partsupp  (cost=0.42..688053.87 rows=1 width=8)
>                                  Index Cond: (ps_partkey = part.p_partkey)
>                                  Filter: ((ps_availqty)::numeric >
> (SubPlan expr_1))
>                                  SubPlan expr_1
>                                    ->  Aggregate
> (cost=172009.42..172009.44 rows=1 width=32)
>                                          ->  Seq Scan on lineitem
> (cost=0.00..172009.42 rows=1 width=5)
>                                                Filter: ((l_shipdate >=
> '1994-01-01'::date) AND (l_shipdate < '1995-01-01 00:00:00'::timestamp
> without time zone) AND (l_partkey = partsupp.ps_partkey) AND
> (l_suppkey = partsupp.ps_suppkey))
>
>
>  Generated Plan Advice:
>    JOIN_ORDER(nation (supplier (part partsupp)))
>    NESTED_LOOP_PLAIN(partsupp partsupp) <--- [X]
>    NESTED_LOOP_MATERIALIZE(partsupp)
>    SEQ_SCAN(nation supplier part lineitem@expr_1)
>    INDEX_SCAN(partsupp public.pk_partsupp)
>    SEMIJOIN_NON_UNIQUE((partsupp part))
>    NO_GATHER(supplier nation partsupp part lineitem@expr_1)

Yeah, that's not right. There are three nested loops here, so we
should have three pieces of nested loop advice.
NESTED_LOOP_MATERIALIZE(partsupp) covers the innermost nested loop.
The other two are NESTED_LOOP_PLAIN, but the advice should cover all
the tables on the inner side of the join. I think it should read:

NESTED_LOOP_PLAIN((part partsupp) (supplier part partsupp))

Ordering isn't significant here, so NESTED_LOOP_PLAIN((part supplier
partsupp) (partsupp part)) would be logically equivalent. Doesn't
matter exactly what we output here, but it shouldn't be just partsupp.

> and apparently proper advice like below which has better yield:
>     set pg_plan_advice.advice = '[..] NESTED_LOOP_PLAIN(part partsupp)

This isn't quite what you want, because this says that part should be
on the outer side of a NESTED_LOOP_PLAIN by itself and partsupp should
also be on the outer side of a NESTED_LOOP_PLAIN by itself. You need
the extra set of parentheses to indicate that the join product of
those two tables should be on the outer side of a NESTED_LOOP_PLAIN,
rather than each table individually.

What must be happening here is that either pgpa_join.c (maybe with
complicity from pgpa_walker.c) is not populating the
pgpa_plan_walker_context's join_strategies[JSTRAT_NESTED_LOOP_PLAIN]
member correctly, or else pgpa_output.c is not serializing it to text
correctly. I suspect the former is a more likely but I'm not sure
exactly what's happening.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-18 20:39  Robert Haas <[email protected]>
  parent: Jakub Wartak <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Robert Haas @ 2025-12-18 20:39 UTC (permalink / raw)
  To: Jakub Wartak <[email protected]>; +Cc: Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Dec 17, 2025 at 8:44 AM Jakub Wartak
<[email protected]> wrote:
> OK, now for the q10:
>
>  Sort
>    Sort Key: (sum((lineitem.l_extendedprice * ('1'::numeric -
> lineitem.l_discount)))) DESC
>    ->  Finalize GroupAggregate
>          Group Key: customer.c_custkey, nation.n_name
>          ->  Gather Merge
>                Workers Planned: 2
>                ->  Partial GroupAggregate
>                      Group Key: customer.c_custkey, nation.n_name
>                      ->  Sort
>                            Sort Key: customer.c_custkey, nation.n_name
>                            ->  Hash Join
>                                  Hash Cond: (customer.c_nationkey =
> nation.n_nationkey)
>                                  ->  Parallel Hash Join
>                                        Hash Cond: (orders.o_custkey =
> customer.c_custkey)
>                                        ->  Nested Loop
>                                              ->  Parallel Seq Scan on orders
>                                                    Filter:
> ((o_orderdate >= '1993-10-01'::date) AND (o_orderdate < '1994-01-01
> 00:00:00'::timestamp without time zone))
>                                              ->  Index Scan using
> lineitem_l_orderkey_idx_l_returnflag on lineitem
>                                                    Index Cond:
> (l_orderkey = orders.o_orderkey)
>                                        ->  Parallel Hash
>                                              ->  Parallel Seq Scan on customer
>                                  ->  Hash
>                                        ->  Seq Scan on nation
>  Generated Plan Advice:
>    JOIN_ORDER(orders lineitem customer nation)
>    NESTED_LOOP_PLAIN(lineitem)
>    HASH_JOIN(customer nation)
>    SEQ_SCAN(orders customer nation)
>    INDEX_SCAN(lineitem public.lineitem_l_orderkey_idx_l_returnflag)
>    GATHER_MERGE((customer orders lineitem nation))

This looks correct to me.

> but when set the advice it generates wrong NL instead of expected
> Parallel HJ (so another way to fix is to simply disable PQ, yuck),
> but:

This is obviously bad. I'm not quite sure what happened here, but my
guess is that something prevented the JOIN_ORDER advice from being
applied cleanly and then everything went downhill from there. I wonder
if JOIN_ORDER doesn't interact properly with incremental sorts --
that's a situation for which I don't think I have existing test
coverage.

> So to me it looks like in Generated Plan Advice we:
> - have proper HASH_JOIN(customer nation)
> - but it somehow forgot to include "HASH_JOIN(orders)" to cover for
> that Parallel Hash Join on (orders.o_custkey = customer.c_custkey)
> with input from NL. After adding that manually, it achieves the same
> input plan properly.

The first table in the JOIN_ORDER() specification isn't supposed to
have a join method specification, because the join method specifier
says what appears on the inner, i.e. second, arm of the join.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-29 23:33  Haibo Yan <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 0 replies; 133+ messages in thread

From: Haibo Yan @ 2025-12-29 23:33 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

Hi Robert,

Thank you very much for your work on the pg_plan_advice patch series. It is
an impressive and substantial contribution, and it seems like a meaningful
step forward toward addressing long-standing query plan stability issues in
PostgreSQL.

While reviewing the v7 patches, I noticed a few points that I wanted to
raise for discussion:
1. GEQO interaction (patch 4):
Since GEQO relies on randomized search, is there a risk that the optimizer
may fail to explore the specific join order or path that is being enforced
by the advice mask? In that case, could this lead to failures such as
inability to construct the required join relation or excessive planning
time if the desired path is not sampled?
2. Parallel query serialization (patches 1–3):
Several new fields (subrtinfos, elidedNodes, child_append_relid_sets) are
added to PlannedStmt, but I did not see corresponding changes in outfuncs.c
/ readfuncs.c. Without serialization support, parallel workers executing
subplans or Append nodes may not receive this metadata. Is this handled
elsewhere, or is it something still pending?
3. Alias handling when generating advice (patch 5):
In pgpa_output_relation_name, the advice string is generated using
get_rel_name(relid), which resolves to the underlying table name rather
than the RTE alias. In self-join cases this could be ambiguous (e.g.,
my_table vs my_table). Would it be more appropriate to use the RTE alias
when available?
4. Minor typo (patch 4):
In src/include/nodes/relation.h, parititonwise appears to be a typo and
should likely be partitionwise.

I hope these comments are helpful, and I apologize in advance if any of
this is already addressed elsewhere in the series.

Best regards,
Haibo

On Mon, Dec 15, 2025 at 12:06 PM Robert Haas <[email protected]> wrote:

> Here's v7.
>
> In 0001, I removed "const" from a node's struct declaration, because
> Tom gave me some feedback to avoid that on another recent patch, and I
> noticed I had done it here also. 0002, 0003, and 0004 are unchanged.
>
> In 0005:
>
> - Refactored the code to avoid issuing SEMIJOIN_NON_UNIQUE() advice in
> cases where uniqueness wasn't actually considered.
> - Adjusted the code not to issue NO_GATHER() advice for non-relation
> RTEs. (This is the issue reported by Ajay Pal in a recent message to
> this thread, which was also mentioned in an XXX in the code.)
> - Reject zero-length delimited identifiers, per Jacob's email.
> - Properly initialize element->indexes in pgpa_trove_add_to_hash, per
> Jacob'e email.
> - Add gather((d d/d.d)) test case, per Jacob, and fix the related bug
> in pgpa_identifier_matches_target, per Jacob's email.
> - Add EXPLAIN SELECT 1 after various test cases in syntax.sql, to
> improve test coverage, per analysis of why the existing test case
> didn't catch a bug previously reported by Jacob.
> - Added a dummy initialization to pgpa_collector.c to placate nervous
> compilers, per discussion with Jacob.
>
> I think this mostly catches me up on responding to issues reported
> here, although there is one thing reported to me off-list that I
> haven't dealt with yet. If there's anything reported on thread that
> isn't addressed here, let me know.
>
> Thanks,
>
> --
> Robert Haas
> EDB: http://www.enterprisedb.com
>


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2025-12-30 01:15  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 2 replies; 133+ messages in thread

From: Lukas Fittl @ 2025-12-30 01:15 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

--000000000000e7f86106472119c4
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable

On Mon, Dec 15, 2025 at 12:06=E2=80=AFPM Robert Haas <[email protected]=
> wrote:
> Here's v7.

I'm excited about this patch series, and in an effort to help land the
infrastructure, here is a review of 0001 - 0003 to start:

For 0001, I'm not sure the following comment is correct:

> /* When recursing =3D true, it's an unplanned or dummy subquery. */
> rtinfo->dummy =3D recursing;

Later in that function we only recurse if its a dummy subquery - in the
case of an unplanned subquery (rel->subroot =3D=3D NULL)
add_rtes_to_flat_rtable won't be called again (instead the relation RTEs
are directly added to the finalrtable). Maybe we can
clarify that comment as "When recursing =3D true, it's a dummy subquery or
its children.".



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-02 04:10  David G. Johnston <[email protected]>
  parent: Lukas Fittl <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: David G. Johnston @ 2026-03-02 04:10 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexandra Wang <[email protected]>; Richard Guo <[email protected]>; Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Feb 27, 2026 at 6:16 PM David G. Johnston <
[email protected]> wrote:

> On Fri, Feb 27, 2026 at 3:46 PM Robert Haas <[email protected]> wrote:
>
>> On Thu, Feb 26, 2026 at 8:55 AM Robert Haas <[email protected]>
>> wrote:
>> > Thanks, Alex, for the review.
>>
>> Here's v18. In addition to fixing the problems pointed out by Alex,
>> there are a couple of significant changes in this version.
>>
>>
> I have a mind to walk through the readmes and sgmls but its going to be in
> chunks.  Here's one for the readme for pg_plan_advice with a couple of
> preliminary sgml changes.
>
>
0003 sgml focus with some readme.

There is an inconsistency between readme and sgml regarding the "join
(strategy|method) advice" label.

The wording for partitionwise is better in the readme than the sgml.

I did make some bulkier suggestions - they do not contain proper markup.

There may be some repeated suggestions from my previous review - I didn't
try to match up what you did and did not take in.

I re-ordered semijoin to be alphabetical - which also had the benefit of
matching the layout of the paragraph.  Flipping the order of "former" and
"latter" is quite intentional.

I defined what "successfully enforced" means in the emit warning GUC.  That
was my unresearched guess after reading how "failed" behaves.

I found "negative join order constraint" challenging to parse.  I tried to
word it more like what is done in the readme.

I don't know if this conflicts with my previous diff of the same patch.  A
couple of overlap spots possibly but they were largely independent (readme
then, sgml now).

David J.


Attachments:

  [text/x-patch] nocfbot-v18-0003-pg_plan_advice-sgml.diff (16.6K, 3-nocfbot-v18-0003-pg_plan_advice-sgml.diff)
  download | inline diff:
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
index 0b888fd82f2..441bd79da50 100644
--- a/contrib/pg_plan_advice/README
+++ b/contrib/pg_plan_advice/README
@@ -141,7 +141,7 @@ side and some kind of join between t2 and t3 on the inner side, but without
 saying how that join must be performed or anything about which relation should
 appear on which side of the join, or even whether this kind of join has sides.
 
-Join Strategy Advice
+Join Method Advice
 ====================
 
 Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
@@ -151,10 +151,10 @@ side of a plain nested loop (one without materialization or memoization)
 and that it should also put a join between the relation whose identifier is
 "y" and the relation whose identifier is "z" on the inner side of a nested
 loop. Hence, for an N-table join problem, there will be N-1 pieces of join
-strategy advice; no join strategy advice is required for the outermost
+method advice; no join method advice is required for the outermost
 table in the join problem.
 
-Considering that we have both join order advice and join strategy advice,
+Considering that we have both join order advice and join method advice,
 it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
 to mean that x should appear by itself on one side or the other of a nested
 loop, rather than specifically on the inner side, but this definition appears
@@ -166,13 +166,13 @@ join, the two sides are treated very differently and saying that a certain
 relation should be involved in one of those operations without saying which
 role it should take isn't saying much.
 
-This choice of definition implies that join strategy advice also imposes some
+This choice of definition implies that join method advice also imposes some
 join order constraints. For example, given a join between foo and bar,
 HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
 be impossible to put bar beneath the inner side of a Hash Join.
 
 Note that, given this definition, it's reasonable to consider deleting the
-join order advice but applying the join strategy advice. For example,
+join order advice but applying the join method advice. For example,
 consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
 The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
 dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
@@ -218,6 +218,9 @@ in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
 effect, since there would be no other rels to which t3 could be joined in
 a partitionwise fashion.
 
+Note that, regardless of what advice is specified, no partitionwise joins
+will be possible if enable_partitionwise_join = off.
+
 Parallel Query (Gather, etc.)
 =============================
 
diff --git a/doc/src/sgml/pgplanadvice.sgml b/doc/src/sgml/pgplanadvice.sgml
index 817bafa21c6..305f3890ed8 100644
--- a/doc/src/sgml/pgplanadvice.sgml
+++ b/doc/src/sgml/pgplanadvice.sgml
@@ -12,12 +12,12 @@
   to be described, reproduced, and altered using a special-purpose "plan
   advice" mini-language. It is intended to allow stabilization of plan choices
   that the user believes to be good, as well as experimentation with plans that
-  the planner believes to be non-optimal.
+  the planner believes to be suboptimal.
  </para>
 
  <para>
   Note that, since the planner often makes good decisions, overriding its
-  judgement can easily backfire. For example, if the distribution of the
+  judgment can easily backfire. For example, if the distribution of the
   underlying data changes, the planner normally has the option to adjust the
   plan in an attempt to preserve good performance. If the plan advice prevents
   this, a very poor plan may be chosen. It is important to use plan advice
@@ -29,9 +29,8 @@
   <title>Getting Started</title>
 
   <para>
-   In order to use this module, the <literal>pg_plan_advice</literal> module
-   must be loaded. You can do this on a system-wide basis by adding
-   <literal>pg_plan_advice</literal> to
+   This module must first be loaded by adding
+   <literal>pg_plan_advice</literal> either to
    <xref linkend="guc-shared-preload-libraries"/> and restarting the
    server, or by adding it to
    <xref linkend="guc-session-preload-libraries"/> and starting a new session,
@@ -43,7 +42,7 @@
    Once the <literal>pg_plan_advice</literal> module is loaded,
    <link linkend="sql-explain"><literal>EXPLAIN</literal></link> will support
    a <literal>PLAN_ADVICE</literal> option. You can use this option to see
-   a plan advice string for the chosen plan. For example:
+   the plan advice string for the chosen plan. For example:
   </para>
 
 <programlisting>
@@ -133,15 +132,15 @@ EXPLAIN (COSTS OFF)
   <para>
    Plan advice is written imperatively; that is, it specifies what should be
    done. However, at an implementation level,
-   <literal>pg_plan_advice</literal> works by telling the core planner what
+   <literal>pg_plan_advice</literal> works by telling the planner what
    should not be done. In other words, it operates by constraining the
    planner's choices, not by replacing it. Therefore, no matter what advice
-   you provide, you will only ever get a plan that the core planner would have
+   you provide, you will only ever get a plan that the planner would have
    considered for the query in question. If you attempt to force what you
    believe to be the correct plan by supplying an advice string, and the
    planner still fails to produce the desired plan, this means that either
    there is a bug in your advice string, or the plan in question was not
-   considered viable by the core planner. This commonly happens for one of two
+   considered viable by the planner. This commonly happens for one of two
    reasons. First, it might be that the planner believes that the plan you're
    trying to force would be semantically incorrect - that is, it would produce
    the wrong results - and for that reason it wasn't considered. Second, it
@@ -200,7 +199,7 @@ EXPLAIN (COSTS OFF)
 
   <para>
    An <firstterm>advice target</firstterm> uniquely identifies a particular
-   instance of a particular relation involved in a particular query. In simple
+   instance of a particular relation involved in a particular (sub)plan. In simple
    cases, such as the examples shown above, the advice target is simply the
    relation alias. However, a more complex syntax is required when subqueries
    are used, when tables are partitioned, or when the same relation alias is
@@ -237,8 +236,8 @@ alias_name#occurrence_number/partition_schema.partition_name@plan_name
    and uses the parent query's subplan name (or no subplan name, if pulled up
    to the top level). Furthermore, the correct subquery name is sometimes not
    obvious. For example, when two queries are joined using an operation such as
-   <literal>UNION</literal> or <literal>INTERSECT</literal>, no name for the
-   subqueries is present in the SQL syntax; instead, a system-generated name is
+   <literal>UNION</literal> or <literal>INTERSECT</literal>, no names for the
+   subqueries are present in the SQL syntax; instead, a system-generated name is
    assigned to each branch. The easiest way to discover the proper advice
    targets is to use <literal>EXPLAIN (PLAN_ADVICE)</literal> and examine the
    generated advice.
@@ -362,7 +361,7 @@ Join
 
    <para>
     Parenthesized sublists can be arbitrarily nested, but sublists surrounded
-    by curly braces cannot themselves contain sublists.
+    by curly braces cannot contain sublists.
    </para>
 
    <para>
@@ -371,7 +370,7 @@ Join
     are multiple join problems that are optimized separately by the planner.
     This can happen due to the presence of subqueries, or because there is a
     partitionwise join. In the latter case, each branch of the partitionwise
-    join can have its own join order, independent of every other branch.
+    join can have its own join order.
    </para>
 
   </sect3>
@@ -405,10 +404,11 @@ join_method_name(<replaceable>join_method_item</replaceable> [ ... ])
    </para>
 
    <para>
-    Note that join method advice implies a negative join order constraint.
+    Note that join method advice necessarily constrains the available join orders.
     Since the named relation or relations must be on the inner side of a join
     using the specified method, none of them can be the driving table for the
-    entire join problem. Moreover, no relation inside the set should be joined
+    entire join problem (i.e., JOIN_ORDER(a b) and HASH_JOIN(a) are conflicting).
+    Moreover, no relation inside the set should be joined
     to any relation outside the set until all relations within the set have
     been joined to each other. For example, if the advice specifies
     <literal>HASH_JOIN((a b))</literal> and the system begins by joining either
@@ -416,7 +416,7 @@ join_method_name(<replaceable>join_method_item</replaceable> [ ... ])
     plan could never be compliant with the request to put exactly those two
     relations on the inner side of a hash join. When using both join order
     advice and join method advice for the same query, it is a good idea to make
-    sure that they do not mandate incompatible join orders.
+    sure that they do not mandate conflicting join orders.
    </para>
 
   </sect3>
@@ -432,13 +432,16 @@ PARTITIONWISE(<replaceable>partitionwise_item</replaceable> [ ... ])
 ( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
 
    <para>
-    When applied to a single target, <literal>PARTITIONWISE</literal>
-    specifies that the specified table should not be part of any partitionwise
-    join. When applied to a list of targets, <literal>PARTITIONWISE</literal>
-    specifies that exactly that set of relations should be joined in
-    partitionwise fashion. Note that, regardless of what advice is specified,
-    no partitionwise joins will be possible if
-    <literal>enable_partitionwise_join = off</literal>.
+    PARTITIONWISE() advise can be used to specify both those partitionwise joins
+    which should be performed and those which should not be performed; the idea
+    is that each argument to PARTITIONWISE specifies a set of relations that
+    should be scanned partitionwise after being joined to each other and nothing
+    else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+    query should contain a partitionwise join between t1 and t2 and that t3
+    should not be part of any partitionwise join. If there are no other rels
+    in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+    effect, since there would be no other rels to which t3 could be joined in
+    a partitionwise fashion.
    </para>
 
   </sect3>
@@ -446,8 +449,8 @@ PARTITIONWISE(<replaceable>partitionwise_item</replaceable> [ ... ])
   <sect3 id="pgplanadvice-semijoin-unique">
   <title>Semijoin Uniqueness Advice</title>
    <synopsis>
-SEMIJOIN_UNIQUE(<replaceable>sj_unique_item</replaceable> [ ... ])
 SEMIJOIN_NON_UNIQUE(<replaceable>sj_unique_item</replaceable> [ ... ])
+SEMIJOIN_UNIQUE(<replaceable>sj_unique_item</replaceable> [ ... ])
 
 <phrase>where <replaceable>sj_unique_item</replaceable> is:</phrase>
 
@@ -457,9 +460,9 @@ SEMIJOIN_NON_UNIQUE(<replaceable>sj_unique_item</replaceable> [ ... ])
    <para>
     The planner sometimes has a choice between implementing a semijoin
     directly and implementing a semijoin by making the nullable side unique
-    and then performing an inner join. <literal>SEMIJOIN_UNIQUE</literal>
-    specifies the latter strategy, while <literal>SEMIJOIN_NON_UNIQUE</literal>
-    specifies the former strategy. In either case, the argument is the single
+    and then performing an inner join. <literal>SEMIJOIN_NON_UNIQUE</literal>
+    specifies the former strategy, while <literal>SEMIJOIN_UNIQUE</literal>
+    specifies the latter strategy. In either case, the argument is the single
     relation or list of relations that appear beneath the nullable side of the
     join.
    </para>
@@ -545,11 +548,11 @@ NO_GATHER(<replaceable>advice_target</replaceable> [ ... ])
 
 <programlisting>
 SET pg_plan_advice.advice = 'hash_join(f g) join_order(f g) index_scan(f no_such_index)';
-SET
-rhaas=# EXPLAIN (COSTS OFF)                                                     SELECT * FROM jo_fact f
-        LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
-        LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
-        WHERE val1 = 1 AND val2 = 1;
+EXPLAIN (COSTS OFF) 
+        SELECT * FROM jo_fact f
+            LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+            LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+            WHERE val1 = 1 AND val2 = 1;
                             QUERY PLAN
 -------------------------------------------------------------------
  Hash Join
@@ -577,12 +580,16 @@ rhaas=# EXPLAIN (COSTS OFF)
    on the inner side of a hash join is listed as
    <literal>not matched</literal>. The <literal>JOIN_ORDER</literal> advice
    tag involves one valid target and one invalid target, and so is listed as
-   <literal>partially matched</literal>. Note that
-   <literal>HASH_JOIN(f g)</literal> is actually a request for two logically
-   separate behaviors, whereas <literal>JOIN_ORDER(f g)</literal> is a single
-   request. When providing advice feedback, <literal>EXPLAIN</literal> shows
-   each logical request separately, together with all the feedback applicable
-   to that request type.
+   <literal>partially matched</literal>.
+  </para>
+
+  <para> 
+   Feedback advice is normalized into logical directives. Note, in the example,
+   that the <literal>HASH_JOIN(f g)</literal> join method advice was decomposed
+   into its two logically separate behaviors, <literal>HASH_JOIN(f)</literal>
+   and <literal>HASH_JOIN(g)</literal>, in the feedback. Since
+   <literal>JOIN_ORDER(f g)</literal> is a single logical request it appears
+   as-is in the feedback.
   </para>
 
   <para>
@@ -594,17 +601,17 @@ rhaas=# EXPLAIN (COSTS OFF)
    <listitem>
     <para>
      <literal>matched</literal> means that all of the specified advice targets
-     were observed together during query planning, at a time at which the
+     were observed together during query planning, at times when the
      advice could be enforced.
     </para>
    </listitem>
 
    <listitem>
     <para>
-     <literal>partially matched</literal> means that some but not all of the
-     specified advice targets were observed during query planning, or all
-     of the advice targets were observed but not together. For example, this
-     may happen if all the targets of <literal>JOIN_ORDER</literal> advice
+     <literal>partially matched</literal> means either that only some of the
+     advice targets were observed during query planning, or all
+     of the advice targets were observed but not at the right times. For example,
+     this may happen if all the targets of <literal>JOIN_ORDER</literal> advice
      individually match the query, but the proposed join order is not legal.
     </para>
    </listitem>
@@ -663,6 +670,15 @@ rhaas=# EXPLAIN (COSTS OFF)
    <literal>partially matched</literal>, or <literal>not matched</literal>.
   </para>
 
+  <para>
+   Thus, in the example above, INDEX_SCAN(f no_such_index) matched because the
+   only named target in the advice, f, is present in the query. The attempt to
+   locate no_such_index failed, marking the advice inapplicable. Since the plan
+   did not include an index scan, but matched the target, the additional failed
+   marking is applied.  Both partially matched and not matched imply a failed
+   outcome so the additional marking is omitted.
+  </para>
+
  </sect2>
 
  <sect2 id="pgplanadvice-config-params">
@@ -719,8 +735,8 @@ rhaas=# EXPLAIN (COSTS OFF)
      <para>
       <varname>pg_plan_advice.always_store_advice_details</varname> allows
       <literal>EXPLAIN</literal> to show details related to plan advice even
-      when prepared queries are used.  The default value is
-      <literal>false</literal>.  When planning a prepared query, it is not
+      when prepared queries are used. The default value is
+      <literal>false</literal>. When planning a prepared query, it is not
       possible to know whether <literal>EXPLAIN</literal> will later be used,
       so by default, to reduce overhead, <literal>pg_plan_advice</literal>
       will not generate plan advice or feedback on supplied advice. This means
@@ -747,6 +763,9 @@ rhaas=# EXPLAIN (COSTS OFF)
       emits a warning whenever supplied plan advice is not successfully
       enforced. The default value is <literal>false</literal>.
      </para>
+     <para>
+      Success means that all advice feedback is marked as matched, and not failed.
+     </para>
     </listitem>
    </varlistentry>
 


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-02 22:09  David G. Johnston <[email protected]>
  parent: David G. Johnston <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: David G. Johnston @ 2026-03-02 22:09 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexandra Wang <[email protected]>; Richard Guo <[email protected]>; Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sun, Mar 1, 2026 at 9:10 PM David G. Johnston <[email protected]>
wrote:

> On Fri, Feb 27, 2026 at 6:16 PM David G. Johnston <
> [email protected]> wrote:
>
>> On Fri, Feb 27, 2026 at 3:46 PM Robert Haas <[email protected]>
>> wrote:
>>
>>> On Thu, Feb 26, 2026 at 8:55 AM Robert Haas <[email protected]>
>>> wrote:
>>> > Thanks, Alex, for the review.
>>>
>>> Here's v18. In addition to fixing the problems pointed out by Alex,
>>> there are a couple of significant changes in this version.
>>>
>>>
>> I have a mind to walk through the readmes and sgmls but its going to be
>> in chunks.  Here's one for the readme for pg_plan_advice with a couple of
>> preliminary sgml changes.
>>
>>
> 0003 sgml focus with some readme.
>
>
And now 0004 sgml (no readme):

My OCD wants these named pg_advice_{plan,collect,stash} so they sort
together.

Strongly thinking using "entries" throughout makes more sense than "query
texts and advice string" - it is shorter and more inclusive since the
actual stored info covers IDs and timestamp.

I made one swap where shared was being mentioned before local.

I added some unresearched answers to open questions I had at the end of the
main section.  Namely, pertaining to advice feedback output and capturing
explain plans themselves.

David J.


Attachments:

  [text/x-patch] nocfbot-v18-0004-pgcollectadvice-sgml.diff (6.5K, 3-nocfbot-v18-0004-pgcollectadvice-sgml.diff)
  download | inline diff:
diff --git a/doc/src/sgml/pgcollectadvice.sgml b/doc/src/sgml/pgcollectadvice.sgml
index fd7d879d816..ba2837c3982 100644
--- a/doc/src/sgml/pgcollectadvice.sgml
+++ b/doc/src/sgml/pgcollectadvice.sgml
@@ -11,11 +11,16 @@
   The <filename>pg_collect_advice</filename> extension allows you to
   automatically generate plan advice each time a query is planned and store
   the query and the generated advice string either in local or shared memory.
+  Collection enablement and entries are described below, but importantly,
+  there is no deduplication involved; each planning event produces one entry,
+  and a mandatory cap on the number of entries allowed must be set before
+  enabling collection.
   Note that this extension requires the <xref linkend="pgplanadvice" /> module,
-  which performs the actual plan advice generation; this module only knows
-  how to store the generated advice for later examination. Whenever
-  <literal>pg_collect_advice</literal> is loaded, it will automatically load
-  <literal>pg_plan_advice</literal>.
+  which performs the actual plan advice generation; this module just handles
+  enabling automatic generation and performing the storage of the generated
+  advice for later examination through the extension's views.
+  Whenever <literal>pg_collect_advice</literal> is loaded, it will automatically
+  load <literal>pg_plan_advice</literal>.
  </para>
 
  <para>
@@ -30,12 +35,14 @@
  </para>
 
  <para>
-  <literal>pg_collect_advice</literal> includes both a shared advice
-  collector and a local advice collector. The local advice collector makes
-  queries and their advice strings visible only to the session where those
-  queries were planned, while the shared advice collector collects data
-  on a system-wide basis, and authorized users can examine data from all
-  sessions.
+  <literal>pg_collect_advice</literal> includes both a local advice
+  collector and a shared advice collector.
+  The local advice collector stores entries locally to the session where those
+  queries were planned and requires the extension to be installed within the
+  database the session is connected to.
+  The shared advice collector stores entries for all databases in the cluster,
+  and authorized users can examine entries from any session connected to a
+  database where the extension is installed.
  </para>
 
  <para>
@@ -67,14 +74,26 @@
  </para>
 
  <para>
-  In addition to the query texts and advice strings, the advice collectors
-  will also store the OID of the role that caused the query to be planned,
+  For each entry in a collector, in addition to the query texts and advice
+  strings, the advice collector will also store the OID of the role that caused the query to be planned,
   the OID of the database in which the query was planned, the query ID,
   and the time at which the collection occurred. This module does not
   automatically enable query ID computation; therefore, if you want the
-  query ID value to be populated in collected advice, be sure to configure
-  <literal>compute_query_id = on</literal>. Otherwise, the query ID may
-  always show as <literal>0</literal>.
+  query ID value to be populated in collected advice, be sure the setting
+  <literal>compute_query_id</literal> is set to <literal>on</literal>,
+  otherwise the query ID will appear as <literal>0</literal>.
+ </para>
+
+ <para>
+  The pg_plan_advice module also produces feedback; the collector
+  is unable to capture such feedback as it relies on running explain
+  with PLAN_ADVICE which does not happen during normal execution.
+ </para>
+
+ <para>
+  Plan advice is not a substitute for seeing a query plan. The auto_explain
+  module can be used (imprecisely) in conjunction with this module to add explain
+  output to the corpus of planner data automatically captured for executed queries.
  </para>
 
  <sect2 id="pgcollectadvice-functions">
@@ -92,8 +111,7 @@
 
     <listitem>
      <para>
-      Removes all collected query texts and advice strings from backend-local
-      memory.
+      Removes all collected entries from backend-local memory.
      </para>
     </listitem>
    </varlistentry>
@@ -110,8 +128,7 @@
 
     <listitem>
      <para>
-      Returns all query texts and advice strings stored in the local
-      advice collector.
+      Returns all entries stored in the local advice collector.
      </para>
     </listitem>
    </varlistentry>
@@ -126,8 +143,7 @@
 
     <listitem>
      <para>
-      Removes all collected query texts and advice strings from shared
-      memory.
+      Removes all collected entires from shared memory.
      </para>
     </listitem>
    </varlistentry>
@@ -144,8 +160,7 @@
 
     <listitem>
      <para>
-      Returns all query texts and advice strings stored in the shared
-      advice collector.
+      Returns all entries stored in the shared advice collector.
      </para>
     </listitem>
    </varlistentry>
@@ -170,7 +185,7 @@
     <listitem>
      <para>
       <varname>pg_collect_advice.local_collector</varname> enables the
-      local advice collector.  The default value is <literal>false</literal>.
+      local advice collector. The default value is <literal>false</literal>.
      </para>
     </listitem>
    </varlistentry>
@@ -186,8 +201,8 @@
     <listitem>
      <para>
       <varname>pg_collect_advice.local_collection_limit</varname> sets the
-      maximum number of query texts and advice strings retained by the
-      local advice collector.  The default value is <literal>0</literal>.
+      maximum number of entries retained by the local advice collector.
+      The default value is <literal>0</literal>.
      </para>
     </listitem>
    </varlistentry>
@@ -203,7 +218,7 @@
     <listitem>
      <para>
       <varname>pg_collect_advice.shared_collector</varname> enables the
-      shared advice collector.  The default value is <literal>false</literal>.
+      shared advice collector. The default value is <literal>false</literal>.
       Only superusers and users with the appropriate <literal>SET</literal>
       privilege can change this setting.
      </para>
@@ -221,8 +236,8 @@
     <listitem>
      <para>
       <varname>pg_collect_advice.shared_collection_limit</varname> sets the
-      maximum number of query texts and advice strings retained by the
-      shared advice collector.  The default value is <literal>0</literal>.
+      maximum number of entries retained by the shared advice collector.
+      The default value is <literal>0</literal>.
       Only superusers and users with the appropriate <literal>SET</literal>
       privilege can change this setting.
      </para>


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-02 23:11  David G. Johnston <[email protected]>
  parent: David G. Johnston <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: David G. Johnston @ 2026-03-02 23:11 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexandra Wang <[email protected]>; Richard Guo <[email protected]>; Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Mar 2, 2026 at 3:09 PM David G. Johnston <[email protected]>
wrote:

> On Sun, Mar 1, 2026 at 9:10 PM David G. Johnston <
> [email protected]> wrote:
>
>> On Fri, Feb 27, 2026 at 6:16 PM David G. Johnston <
>> [email protected]> wrote:
>>
>>> On Fri, Feb 27, 2026 at 3:46 PM Robert Haas <[email protected]>
>>> wrote:
>>>
>>>> On Thu, Feb 26, 2026 at 8:55 AM Robert Haas <[email protected]>
>>>> wrote:
>>>> > Thanks, Alex, for the review.
>>>>
>>>> Here's v18. In addition to fixing the problems pointed out by Alex,
>>>> there are a couple of significant changes in this version.
>>>>
>>>>
>>> I have a mind to walk through the readmes and sgmls but its going to be
>>> in chunks.  Here's one for the readme for pg_plan_advice with a couple of
>>> preliminary sgml changes.
>>>
>>>
>> 0003 sgml focus with some readme.
>>
>>
> And now 0004 sgml (no readme):
>
>
Lastly, 0007 sgml (stash)

Placed entry in correct position on contrib page.

Expanded a bit on the security aspect comment.  I suppose there is some
indirect exposure via decisions being made implying table sizes or
records-per-FK...I left that unmentioned.

Added some explicit limitations to user-supplied values.

I would personally like to see "pg_set_stashed_advice" returning something
besides void.  I would usually go for text, but maybe in the interest of
i18n an integer would suffice.  +1 if a row was added, -1 if a row is
removed, or 0 for an update.  That would necessitate any other no-op being
an error.  Presently, supplying NULL for the query_id is not an error -
that seems like an oversight.  Same goes for supplying NULL for
stash_name.  If both of those cases produce errors (leaving NULL
advice_string being a remove indicator) the integer return seems like it
should work just fine.

Sorta feels like this module would appreciate advice strings having a
comment feature - so instead of just leaving an empty string behind saying
"we know, no advice needed" - it could contain actual content that isn't
applied advice.  Given the complexity of planning, comments seem warranted
in the advice themselves in any case.

Feels like this module needs export and import functions, especially given
the intro paragraph about the contents being volatile.  Or maybe a psql
script example producing a dynamic script file involving \gexec.

David J.


Attachments:

  [text/x-patch] nocfbot-v18-0007-pgstashadvice-sgml.diff (5.0K, 3-nocfbot-v18-0007-pgstashadvice-sgml.diff)
  download | inline diff:
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index 62af2b21fd7..8f09d728698 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -151,7 +151,6 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &ltree;
  &pageinspect;
  &passwordcheck;
- &pgstashadvice;
  &pgbuffercache;
  &pgcollectadvice;
  &pgcrypto;
@@ -161,6 +160,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
index d1878f4f7c1..79b89beba38 100644
--- a/doc/src/sgml/pgstashadvice.sgml
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -10,7 +10,7 @@
  <para>
   The <filename>pg_stash_advice</filename> extension allows you to stash
   <link linkend="pgplanadvice">plan advice</link> strings in dynamic
-  shared memory where they can be automatically applied.  An
+  shared memory where they can be automatically applied. An
   <literal>advice stash</literal> is a mapping from
   <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
   strings. Whenever a session is asked to plan a query whose query ID appears
@@ -25,7 +25,7 @@
   In order to use this module, you will need to execute
   <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
   one database, so that you have access to the SQL functions to manage
-  advice stashes.  You will also need the <literal>pg_stash_advice</literal>
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
   module to be loaded in all sessions where you want this module to
   automatically apply advice. It will usually be best to do this by adding
   <literal>pg_stash_advice</literal> to
@@ -54,12 +54,12 @@
   string that you wish to store for each query. One way to do this is to use
   <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
   show the query ID, and the <literal>PLAN_ADVICE</literal> option will
-  show plan advice.  <xref linkend="pgcollectadvice" /> can be used to
+  show plan advice. <xref linkend="pgcollectadvice" /> can be used to
   obtain this information for an entire workload, although care must be
   taken since it can use up a lot of memory very quickly. Query identifiers can
   also be obtained through tools such as <xref linkend="pgstatstatements" />
   or <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
-  will not provide plan advice strings.  Note that
+  will not provide plan advice strings. Note that
   <xref linkend="guc-compute-query-id" /> must be enabled for query
   identifiers to be computed; if set to <literal>auto</literal>, loading
   <literal>pg_stash_advice</literal> will enable it automatically.
@@ -84,7 +84,9 @@
   <literal>pg_stash_advice.stash_name</literal> for their session, and this
   may reveal the contents of any advice stash with that name. Users should
   assume that information embedded in stashed advice strings may become visible
-  to nonprivileged users.
+  to nonprivileged users. However, advice targets are the only explicit
+  userspace data present in advice strings and most catalog contents are
+  considered non-privileged in PostgreSQL.
  </para>
 
  <sect2 id="pgstashadvice-functions">
@@ -102,7 +104,8 @@
 
     <listitem>
      <para>
-      Creates a new, empty advice stash with the given name.
+      Creates a new, empty advice stash with the given name, which must not be
+      zero length.
      </para>
     </listitem>
    </varlistentry>
@@ -134,10 +137,13 @@
     <listitem>
      <para>
       Stores an advice string in the named advice stash, associated with
-      the given query identifier.  If an entry for that query identifier
-      already exists in the stash, it is replaced.  If
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
       <parameter>advice_string</parameter> is <literal>NULL</literal>,
-      any existing entry for that query identifier is removed.
+      any existing entry for that query identifier is removed. A zero length advice
+      string is stored in the stash. An error is raised if stash_name does
+      not exist. Passing NULL for the query identifier results in a silent no-op,
+      and passing 0 is not permitted, but otherwise, any bigint value is accepted.
      </para>
     </listitem>
    </varlistentry>
@@ -170,7 +176,7 @@
 
     <listitem>
      <para>
-      Returns one row for each entry in the named advice stash.  If
+      Returns one row for each entry in the named advice stash. If
       <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
       entries from all stashes.
      </para>
@@ -197,7 +203,7 @@
     <listitem>
      <para>
       Specifies the name of the advice stash to consult during query
-      planning.  The default value is the empty string, which disables
+      planning. The default value is the empty string, which disables
       this module.
      </para>
     </listitem>


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-04 15:17  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-04 15:17 UTC (permalink / raw)
  To: David G. Johnston <[email protected]>; +Cc: Alexandra Wang <[email protected]>; Richard Guo <[email protected]>; Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Feb 27, 2026 at 8:16 PM David G. Johnston
<[email protected]> wrote:
> I have a mind to walk through the readmes and sgmls but its going to be in chunks.  Here's one for the readme for pg_plan_advice with a couple of preliminary sgml changes.

While I'm grateful for the feedback, I feel like you tend to suggest a
lot of edits that seem like they're just substituting your
idiosyncratic preferences for mine e.g. writing "types of scan" vs.
"scan types," or writing "additional, separate join problems" vs.
"independent join problems" or "judiciously" vs. "conservatively". I
don't really consider these to be improvements, nor do I necessarily
think they're worse, but I just don't see the point in litigating this
kind of stuff. If I've written something that is legitimately unclear,
or factually incorrect, or there's a spelling or punctuation mistake,
I'm happy to correct that kind of stuff, but I don't really want to go
through and replace a bunch of words that I liked with a bunch of
synonyms that you picked.

Also, when you just provide a diff like this, it's not that clear to
me why you're suggesting particular changes, which makes it hard to
decide whether I agree with them. And in a lot of cases I don't.
Looking at some particular examples:

+isn't going to work any more. That's expected. It should be resilient to
+changes in the statistics, including any CREATE STATISTICS related changes.

This is broadly true but seems a bit obvious to mention in a README.
If we were going to mention it, I'd think it would go in the
user-facing documentation. But I don't quite see why we should mention
it at all. If plan advice couldn't override changes caused by
statistics, what would even be the point of it? Also, it's not
categorically true in all situations, because as discussed elsewhere,
we have limitations like lack of control over aggregation strategy.

 Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
-perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+perform a certain join - with the target appearing directly on the inner side
+of the join list first. Thus, NESTED_LOOP_PLAIN(x (y z)) says
 that the plan should put the relation whose identifier is "x" on the inner
 side of a plain nested loop (one without materialization or memoization)
 and that it should also put a join between the relation whose identifier is

This seems like you're adding a second explanation of what the
paragraph already goes onto say, except that the existing explanation
is more precise and detailed.

-useless in practice. It gives the planner too much freedom to do things that
+problematic in practice. It gives the planner too much freedom to do
things that

I mean, I stand by the word I picked. I don't want to weaken it.

-This means that if advice can say that a certain optimization or technique
-should be used, it should also be able to say that the optimization or
-technique should not be used. We should never assume that the absence of an
-instruction to do a certain thing means that it should not be done; all
-instructions must be explicit.
+In other words, advice tags must define whether they encourage or discourage
+certain optimizations or techniques. (NO_GATHER is an example of the latter.
+There is no generic "NOT" syntax, e.g., NOT(HASH_JOIN(dim2 dim4).))

My text explains an important design principle that future hackers
must keep in mind when modifying this system to avoid breaking
everything. Your replacement text just describes how it works today.
Considering that this is a README for hackers, I think that's much
worse.

-  advice" mini-language. It is intended to allow stabilization of plan choices
+  advice" domain specific language (DSL). It is intended to allow
stabilization of plan choices

There's a debate to be had about whether it's better to say
mini-language or domain specific language here, but it's hard for me
to decide which is better if all you provide is a diff replacing A
with B. I definitely think it's worse to write (DSL) here. There is no
point in defining an acronym if we're never going to use it anywhere.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-04 15:44  David G. Johnston <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: David G. Johnston @ 2026-03-04 15:44 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexandra Wang <[email protected]>; Richard Guo <[email protected]>; Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Mar 4, 2026 at 8:17 AM Robert Haas <[email protected]> wrote:

> On Fri, Feb 27, 2026 at 8:16 PM David G. Johnston
> <[email protected]> wrote:
> > I have a mind to walk through the readmes and sgmls but its going to be
> in chunks.  Here's one for the readme for pg_plan_advice with a couple of
> preliminary sgml changes.
>
> While I'm grateful for the feedback, I feel like you tend to suggest a
> lot of edits that seem like they're just substituting your
> idiosyncratic preferences for mine


Yeah, some of these end up being mostly stylistic.  Though I do try to
limit them to ones where I see inconsistency or the style I'm reading just
doesn't resonate with me.  I usually point out the ones that are IMO
material, versus just something that tripped me up while I was reading, but
failed to do so here.

I do need to work in a way to better annotate/comment on the why of these.
Any suggestions for a better flow or feedback format?  Inline comments
wrapped in sgml comments?  Or just copy the diff into the email body and
inline comment there - leaving the original diff attachment as-is?


> -  advice" mini-language. It is intended to allow stabilization of plan
> choices
> +  advice" domain specific language (DSL). It is intended to allow
> stabilization of plan choices
>
> There's a debate to be had about whether it's better to say
> mini-language or domain specific language here, but it's hard for me
> to decide which is better if all you provide is a diff replacing A
> with B. I definitely think it's worse to write (DSL) here. There is no
> point in defining an acronym if we're never going to use it anywhere.
>
>
This was truly just a "have you considered using this terminology instead"
kind of prompt.  The acronym would have been useful when going an replacing
the other uses of mini-language that I left alone since I hadn't myself
decided which one was better.

I didn't do my usual email recap on this first patch which is my bad.  I
corrected that with the others.

David J.


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-04 16:20  Robert Haas <[email protected]>
  parent: David G. Johnston <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-04 16:20 UTC (permalink / raw)
  To: David G. Johnston <[email protected]>; +Cc: Alexandra Wang <[email protected]>; Richard Guo <[email protected]>; Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; Jacob Champion <[email protected]>; Dian Fay <[email protected]>; Matheus Alcantara <[email protected]>; Jakub Wartak <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Mar 4, 2026 at 10:45 AM David G. Johnston
<[email protected]> wrote:
> I do need to work in a way to better annotate/comment on the why of these.  Any suggestions for a better flow or feedback format?  Inline comments wrapped in sgml comments?  Or just copy the diff into the email body and inline comment there - leaving the original diff attachment as-is?

My suggestion is to break these fixes up into three categories: clear
errors, stylistic suggestions, substantive concerns.

Clear errors can be handled by just sending a patch that fixes all the
clear errors without changing anything else. If I intended to write
"applied" and I actually typed "aplied," it's fine to bundle that with
10 other mistakes and submit them without commentary.

For substantive concerns, I think it's most helpful to just quote the
patch hunk in the body of your email and say what your concern is. If
you have suggested wording feel free to suggest that as well, but I'd
focus more on saying what the problem is rather than jumping to the
solution. At least some of these are cases where what I wrote wasn't
sufficient for you to understand the patch, which a very fair issue to
raise, but if you try to write your own wording, the fact that you
don't understand the patch makes it hard for you to write quality
documentation for it. If you say "I read where you said X and I tried
Y and it seemed like the wrong thing happened, so either the
documentation sucks or the code is buggy," now we're having a
worthwhile conversation. If you just change the documentation based on
your understanding of the results of an undisclosed experiment, I feel
like the chances of the result being an improvement are not great.

Stylistic concerns are the most complicated. If your concern is
something like "this sentence is hard to understand," I'd class that
as a substantive concern and treat it the same way. Beyond that, I'm
not really sure. Honestly, I think we may just have different
stylistic preferences, because my experience so far reading your
proposed documentation patches is that I tend to agree with relatively
little of what you want to do. I believe, though, that other
committers feel differently about it and find your proposed changes
quite helpful. So I'm not sure exactly what to recommend here, but
perhaps take a lighter touch when it's my patch? I'm OK with some
friendly suggestions that I can accept or reject, but going through a
huge list of minor wording tweaks is as much work as going through a
substantial review of the code itself, but for a lot less benefit,
especially if they all look like random changes that I don't
understand why you're proposing.

Hope that makes sense and isn't too harsh.

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-06 14:46  David G. Johnston <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: David G. Johnston @ 2026-03-06 14:46 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Wed, Mar 4, 2026 at 9:20 AM Robert Haas <[email protected]> wrote:

> On Wed, Mar 4, 2026 at 10:45 AM David G. Johnston
> <[email protected]> wrote:
> > I do need to work in a way to better annotate/comment on the why of
> these.  Any suggestions for a better flow or feedback format?  Inline
> comments wrapped in sgml comments?  Or just copy the diff into the email
> body and inline comment there - leaving the original diff attachment as-is?
>
> My suggestion is to break these fixes up into three categories: clear
> errors, stylistic suggestions, substantive concerns.
>
>
Thank you for putting in the time to respond.  That was quite helpful.
I've tweaked my tooling to help me remember to do this going forward.

David J.


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-10 14:55  Robert Haas <[email protected]>
  parent: David G. Johnston <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-10 14:55 UTC (permalink / raw)
  To: David G. Johnston <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Fri, Mar 6, 2026 at 9:47 AM David G. Johnston
<[email protected]> wrote:
> Thank you for putting in the time to respond.  That was quite helpful.  I've tweaked my tooling to help me remember to do this going forward.

Thanks. Here's v19. I've incorporated a bunch of your changes, some
that were fixes for clear errors and a few of the more stylistic cases
where I decided that I agreed with you, and I've also fixed some
similar things upon which you did not remark. Separately, I've also
committed the patches that were previously 0001, 0002, and 0005, so
all the preparatory stuff is done now, and this version just contains
the substantive commits, for which I am still in need of review,
especially for 0004. I have also made a minor code adjustment:
pg_plan_advice.trace_mask now prints the subplan name except for the
toplevel "subplan".

Here are a few comments on some of the changes you made that I did not adopt:

- In the README, "we" are the PostgreSQL developers and "the user" is
the person using the module. In the documentation, "you" is the user.
If there are deviations from this idea, we should fix them, but it
seems OK that the two documents have different rules, inasmuch as they
address different audiences.

- For the same reason, I chose not to copy the note about
enable_partitionwise_join=off into the README, as that is not a core
concept for developers but a likely error for users.  We also don't
really want to start duplicating content too much; arguably, we have
too much of that already.

- I don't really think we need to document the exact rules for
argument to, e.g., the pg_stash_advice functions. I think that makes
the documentation ponderous without adding any real utility. At most,
I would mention in some centralized place what the rules for stash
names are. Separately, there is the question of whether the current
naming rules are the right ones.

- In a lot of places, especially in the README, I just disagreed with
your choice of what to emphasize. For instance, I thought my longer
explanation of how we must not infer guidance from the absence of
advice was better than your shorter one, because it's such a critical
point for future developers touching this code to understand. On the
other hand, saying that advice should be stable in the face of
statistics changes was redundant: if it doesn't even do that much,
then what would even be the point?

- I chose to retain the use of the term "core planner" in the SGML
documentation rather than your suggestion of deleting the word "core".
I do not love the wording I've chosen here, but I don't love your
change, either. It seems to me that there is a risk of people being
confused about the distinction between the planning-related logic in
src/backend/optimizer and the planning-related object in
pg_plan_advice itself. I included the word in core for emphasis.
Whether this is the right idea is debatable, of course.

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v19-0004-Add-pg_stash_advice-contrib-module.patch (55.5K, 2-v19-0004-Add-pg_stash_advice-contrib-module.patch)
  download | inline diff:
From 7e1d66228fc5ff50d6083faeb4cbf777903394ae Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 27 Feb 2026 16:58:14 -0500
Subject: [PATCH v19 4/4] Add pg_stash_advice contrib module.

This module allows plan advice strings to be provided automatically
from an in-memory advice stash. Advice stashes are stored in dynamic
shared memory and must be recreated and repopulated after a server
restart. If pg_stash.advice_stash is set to the name of an advice
stash, and if query identifiers are enabled, the query identifier
for each query will be looked up in the advice stash and the
associated advice string, if any, will be used each time that query
is planned.
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_stash_advice/Makefile              |  26 +
 .../expected/pg_stash_advice.out              | 305 ++++++
 contrib/pg_stash_advice/meson.build           |  35 +
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |  43 +
 contrib/pg_stash_advice/pg_stash_advice.c     | 878 ++++++++++++++++++
 .../pg_stash_advice/pg_stash_advice.control   |   5 +
 .../pg_stash_advice/sql/pg_stash_advice.sql   | 130 +++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgstashadvice.sgml               | 218 +++++
 src/tools/pgindent/typedefs.list              |   6 +
 13 files changed, 1650 insertions(+)
 create mode 100644 contrib/pg_stash_advice/Makefile
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice.out
 create mode 100644 contrib/pg_stash_advice/meson.build
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice--1.0.sql
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.c
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.control
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice.sql
 create mode 100644 doc/src/sgml/pgstashadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 22071034e51..14e12d4fe2e 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -30,6 +30,7 @@ SUBDIRS = \
 		oid2name	\
 		pageinspect	\
 		passwordcheck	\
+		pg_stash_advice	\
 		pg_buffercache	\
 		pg_collect_advice \
 		pg_freespacemap \
diff --git a/contrib/meson.build b/contrib/meson.build
index ff422d9b7fc..4862ba97ed1 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -52,6 +52,7 @@ subdir('pg_overexplain')
 subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
+subdir('pg_stash_advice')
 subdir('pg_stat_statements')
 subdir('pgstattuple')
 subdir('pg_surgery')
diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
new file mode 100644
index 00000000000..cd9b7f30115
--- /dev/null
+++ b/contrib/pg_stash_advice/Makefile
@@ -0,0 +1,26 @@
+# contrib/pg_stash_advice/Makefile
+
+MODULE_big = pg_stash_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_stash_advice.o
+
+EXTENSION = pg_stash_advice
+DATA = pg_stash_advice--1.0.sql
+PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
+
+REGRESS = pg_stash_advice
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+ifdef USE_PGXS
+PG_CPPFLAGS = -I$(includedir_server)/extension
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice
+subdir = contrib/pg_stash_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
new file mode 100644
index 00000000000..0de6c10cdd1
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -0,0 +1,305 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
+(13 rows)
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+(13 rows)
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           2
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+ stash_name | advice_string 
+------------+---------------
+(0 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+ERROR:  advice stash "no_such_stash" does not exist
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           1
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   | advice_string 
+---------------+---------------
+ regress_stash | SEQ_SCAN(d1)
+(1 row)
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
+SELECT pg_drop_advice_stash('regress_empty_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
new file mode 100644
index 00000000000..b666bcd0f1b
--- /dev/null
+++ b/contrib/pg_stash_advice/meson.build
@@ -0,0 +1,35 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_stash_advice_sources = files(
+  'pg_stash_advice.c'
+)
+
+if host_system == 'windows'
+  pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_stash_advice',
+    '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',])
+endif
+
+pg_stash_advice = shared_module('pg_stash_advice',
+  pg_stash_advice_sources,
+  include_directories: [pg_plan_advice_inc, include_directories('.')],
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_stash_advice
+
+install_data(
+  'pg_stash_advice--1.0.sql',
+  'pg_stash_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_stash_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pg_stash_advice',
+    ],
+  },
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
new file mode 100644
index 00000000000..88dedd8ef1b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit
+
+CREATE FUNCTION pg_create_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_create_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_drop_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_drop_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint,
+									  advice_string text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_set_stashed_advice'
+LANGUAGE C;
+
+CREATE FUNCTION pg_get_advice_stashes(
+	OUT stash_name text,
+	OUT num_entries bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stashes'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_advice_stash_contents(
+	INOUT stash_name text,
+	OUT query_id bigint,
+	OUT advice_string text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
+LANGUAGE C;
+
+REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
new file mode 100644
index 00000000000..abea9b0e161
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -0,0 +1,878 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.c
+ *	  Apply plan advice automatically, without SQL modifications.
+ *
+ * This module allows plan advice strings (as used and generated by
+ * pg_plan_advice) to be "stashed" in dynamic shared memory and, from
+ * there, automatically be applied to queries as they are planned.
+ * You can create any number of advice stashes, each of which is
+ * identified by a human-readable, ASCII name, and each of them is
+ * essentially a query ID -> advice_string mapping.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "common/string.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "lib/dshash.h"
+#include "nodes/queryjumble.h"
+#include "pg_plan_advice.h"
+#include "storage/dsm_registry.h"
+#include "storage/lwlock.h"
+#include "utils/builtins.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+PG_FUNCTION_INFO_V1(pg_create_advice_stash);
+PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
+PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
+PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
+PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+
+typedef struct pgsa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	int			stash_tranche;
+	int			entry_tranche;
+	int			next_stash_id;
+	dsa_handle	area;
+	dshash_table_handle stash_hash;
+	dshash_table_handle entry_hash;
+} pgsa_shared_state;
+
+typedef struct pgsa_stash
+{
+	char		name[NAMEDATALEN];
+	int			pgsa_stash_id;
+} pgsa_stash;
+
+typedef struct pgsa_entry_key
+{
+	int			pgsa_stash_id;
+	int64		queryId;
+} pgsa_entry_key;
+
+typedef struct pgsa_entry
+{
+	pgsa_entry_key key;
+	dsa_pointer advice_string;
+} pgsa_entry;
+
+typedef struct pgsa_stash_count
+{
+	uint32		status;
+	int			pgsa_stash_id;
+	int64		num_entries;
+} pgsa_stash_count;
+
+#define SH_PREFIX pgsa_stash_count_table
+#define SH_ELEMENT_TYPE pgsa_stash_count
+#define SH_KEY_TYPE int
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) murmurhash32((uint32) key)
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct pgsa_stash_name
+{
+	uint32		status;
+	int			pgsa_stash_id;
+	char	   *name;
+} pgsa_stash_name;
+
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE int
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) murmurhash32((uint32) key)
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/* Shared memory pointers */
+static pgsa_shared_state *pgsa_state;
+static dsa_area *pgsa_dsa_area;
+static dshash_table *pgsa_stash_dshash;
+static dshash_table *pgsa_entry_dshash;
+
+/* Shared memory hash table parameters */
+static dshash_parameters pgsa_stash_dshash_parameters = {
+	NAMEDATALEN,
+	sizeof(pgsa_stash),
+	dshash_strcmp,
+	dshash_strhash,
+	dshash_strcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+static dshash_parameters pgsa_entry_dshash_parameters = {
+	sizeof(pgsa_entry_key),
+	sizeof(pgsa_entry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+/* GUC variable */
+static char *pg_stash_advice_stash_name = "";
+
+/* Other global variables */
+static MemoryContext pg_stash_advice_mcxt;
+
+/* Function prototypes */
+static char *pgsa_advisor(PlannerGlobal *glob,
+						  Query *parse,
+						  const char *query_string,
+						  int cursorOptions,
+						  ExplainState *es);
+static void pgsa_attach(void);
+static void pgsa_check_stash_name(char *stash_name);
+static bool pgsa_check_stash_name_guc(char **newval, void **extra,
+									  GucSource source);
+static void pgsa_clear_advice_string(char *stash_name, int64 queryId);
+static void pgsa_create_stash(char *stash_name);
+static void pgsa_drop_stash(char *stash_name);
+static void pgsa_init_shared_state(void *ptr, void *arg);
+static int	pgsa_lookup_stash_id(char *stash_name);
+static void pgsa_set_advice_string(char *stash_name, int64 queryId,
+								   char *advice_string);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/* If compute_query_id = 'auto', we would like query IDs. */
+	EnableQueryId();
+
+	/* Define our GUCs. */
+	DefineCustomStringVariable("pg_stash_advice.stash_name",
+							   "Name of the advice stash to be used in this session.",
+							   NULL,
+							   &pg_stash_advice_stash_name,
+							   "",
+							   PGC_USERSET,
+							   0,
+							   pgsa_check_stash_name_guc,
+							   NULL,
+							   NULL);
+
+	MarkGUCPrefixReserved("pg_stash_advice");
+
+	/* Tell pg_plan_advice that we want to provide advice strings. */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+	(*add_advisor_fn) (pgsa_advisor);
+}
+
+/*
+ * SQL-callable function to create an advice stash
+ */
+Datum
+pg_create_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_create_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to drop an advice stash
+ */
+Datum
+pg_drop_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_drop_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to provide a list of advice stashes
+ */
+Datum
+pg_get_advice_stashes(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	pgsa_stash_count_table_hash *chash;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Tally up the number of entries per stash. */
+	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		pgsa_stash_count *c;
+		bool		found;
+
+		c = pgsa_stash_count_table_insert(chash,
+										  entry->key.pgsa_stash_id,
+										  &found);
+		if (!found)
+			c->num_entries = 1;
+		else
+			c->num_entries++;
+	}
+	dshash_seq_term(&iterator);
+
+	/* Emit results. */
+	dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[2];
+		bool		nulls[2];
+		pgsa_stash_count *c;
+
+		values[0] = CStringGetTextDatum(stash->name);
+		nulls[0] = false;
+
+		c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id);
+		values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries);
+		nulls[1] = false;
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to provide advice stash contents
+ */
+Datum
+pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	char	   *stash_name = NULL;
+	pgsa_stash_name_table_hash *nhash = NULL;
+	int			stash_id = 0;
+	pgsa_entry *entry;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* User can pass NULL for all stashes, or the name of a specific stash. */
+	if (!PG_ARGISNULL(0))
+	{
+		stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		pgsa_check_stash_name(stash_name);
+		stash_id = pgsa_lookup_stash_id(stash_name);
+
+		/* If the user specified a stash name, it should exist. */
+		if (stash_id == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("advice stash \"%s\" does not exist", stash_name));
+	}
+	else
+	{
+		pgsa_stash *stash;
+
+		/*
+		 * If we're dumping data about all stashes, we need an ID->name lookup
+		 * table.
+		 */
+		nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+		dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+		while ((stash = dshash_seq_next(&iterator)) != NULL)
+		{
+			pgsa_stash_name *n;
+			bool		found;
+
+			n = pgsa_stash_name_table_insert(nhash,
+											 stash->pgsa_stash_id,
+											 &found);
+			Assert(!found);
+			n->name = pstrdup(stash->name);
+		}
+		dshash_seq_term(&iterator);
+	}
+
+	/* Now iterate over all the entries. */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, false);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		char	   *this_stash_name;
+		char	   *advice_string;
+
+		/* Skip incomplete entries where the advice string was never set. */
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		if (stash_id != 0)
+		{
+			/*
+			 * We're only dumping data for one particular stash, so skip
+			 * entries for any other stash and use the stash name specified by
+			 * the user.
+			 */
+			if (stash_id != entry->key.pgsa_stash_id)
+				continue;
+			this_stash_name = stash_name;
+		}
+		else
+		{
+			pgsa_stash_name *n;
+
+			/*
+			 * We're dumping data for all stashes, so look up the correct name
+			 * to use in the hash table. If nothing is found, which is
+			 * possible due to race conditions, make up a string to use.
+			 */
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n != NULL)
+				this_stash_name = n->name;
+			else
+				this_stash_name = psprintf("<stash %d>",
+										   entry->key.pgsa_stash_id);
+		}
+
+		/* Work out tuple values. */
+		values[0] = CStringGetTextDatum(this_stash_name);
+		nulls[0] = false;
+		values[1] = Int64GetDatum(entry->key.queryId);
+		nulls[1] = false;
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+		values[2] = CStringGetTextDatum(advice_string);
+		nulls[2] = false;
+
+		/* Emit the tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to update an advice stash entry for a particular
+ * query ID
+ *
+ * If the second argument is NULL, we delete any existing advice stash
+ * entry; otherwise, we either create an entry or update it with the new
+ * advice string.
+ */
+Datum
+pg_set_stashed_advice(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name;
+	int64		queryId;
+
+	if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+		PG_RETURN_NULL();
+
+	/* Get and check advice stash name. */
+	stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	pgsa_check_stash_name(stash_name);
+
+	/*
+	 * Get and check query ID.
+	 *
+	 * queryID 0 means no query ID was computed, so reject that.
+	 */
+	queryId = PG_GETARG_INT64(1);
+	if (queryId == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("cannot set advice string for query ID 0"));
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Now call the appropriate function to do the real work. */
+	if (PG_ARGISNULL(2))
+		pgsa_clear_advice_string(stash_name, queryId);
+	else
+	{
+		char	   *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+		pgsa_set_advice_string(stash_name, queryId, advice_string);
+	}
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * Get the advice string that has been configured for this query, if any,
+ * and return it. Otherwise, return NULL.
+ */
+static char *
+pgsa_advisor(PlannerGlobal *glob, Query *parse,
+			 const char *query_string, int cursorOptions,
+			 ExplainState *es)
+{
+	pgsa_entry_key key;
+	pgsa_entry *entry;
+	char	   *advice_string;
+	int			stash_id;
+
+	/*
+	 * Exit quickly if the stash name is empty or there's no query ID.
+	 */
+	if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0)
+		return NULL;
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/*
+	 * Translate pg_stash_advice.stash_name to an integer ID.
+	 *
+	 * pgsa_check_stash_name_guc() has already validated the advice stash
+	 * name, so we don't need to call pgsa_check_stash_name() here.
+	 */
+	stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name);
+	if (stash_id == 0)
+		return NULL;
+
+	/*
+	 * Look up the advice string for the given stash ID + query ID.
+	 *
+	 * If we find an advice string, we copy it into the current memory
+	 * context, presumably short-lived, so that we can release the lock on the
+	 * dshash entry. pg_plan_advice only needs the value to remain allocated
+	 * long enough for it to be parsed, so this should be good enough.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = parse->queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		return NULL;
+	if (entry->advice_string == InvalidDsaPointer)
+		advice_string = NULL;
+	else
+		advice_string = pstrdup(dsa_get_address(pgsa_dsa_area,
+												entry->advice_string));
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we found an advice string, emit a debug message. */
+	if (advice_string != NULL)
+		elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s",
+			 pg_stash_advice_stash_name, key.queryId, advice_string);
+
+	return advice_string;
+}
+
+/*
+ * Attach to various structures in dynamic shared memory.
+ *
+ * This function is designed to be resilient against errors. That is, if it
+ * fails partway through, it should be possible to call it again, repeat no
+ * work already completed, and potentially succeed or at least get further if
+ * whatever caused the previous failure has been corrected.
+ */
+static void
+pgsa_attach(void)
+{
+	bool		found;
+	MemoryContext oldcontext;
+
+	/*
+	 * Create a memory context to make sure that any control structures
+	 * allocated in local memory are sufficiently persistent.
+	 */
+	if (pg_stash_advice_mcxt == NULL)
+		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
+													 "pg_stash_advice",
+													 ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
+
+	/* Attach to the fixed-size state object if not already done. */
+	if (pgsa_state == NULL)
+		pgsa_state = GetNamedDSMSegment("pg_stash_advice",
+										sizeof(pgsa_shared_state),
+										pgsa_init_shared_state,
+										&found, NULL);
+
+	/* Attach to the DSA area if not already done. */
+	if (pgsa_dsa_area == NULL)
+	{
+		dsa_handle	area_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		area_handle = pgsa_state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
+			dsa_pin(pgsa_dsa_area);
+			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_dsa_area = dsa_attach(area_handle);
+		}
+		dsa_pin_mapping(pgsa_dsa_area);
+	}
+
+	/* Attach to the stash_name->stash_id hash table if not already done. */
+	if (pgsa_stash_dshash == NULL)
+	{
+		dshash_table_handle stash_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
+		stash_handle = pgsa_state->stash_hash;
+		if (stash_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  NULL);
+			pgsa_state->stash_hash =
+				dshash_get_hash_table_handle(pgsa_stash_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  stash_handle, NULL);
+		}
+	}
+
+	/* Attach to the entry hash table if not already done. */
+	if (pgsa_entry_dshash == NULL)
+	{
+		dshash_table_handle entry_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
+		entry_handle = pgsa_state->entry_hash;
+		if (entry_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  NULL);
+			pgsa_state->entry_hash =
+				dshash_get_hash_table_handle(pgsa_entry_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  entry_handle, NULL);
+		}
+	}
+
+	/* Restore previous memory context. */
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Check whether an advice stash name is legal, and signal an error if not.
+ *
+ * Keep this in sync with pgsa_check_stash_name_guc, below.
+ */
+static void
+pgsa_check_stash_name(char *stash_name)
+{
+	/* Reject empty advice stash name. */
+	if (stash_name[0] == '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name may not be zero length"));
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash names may not be longer than %d bytes",
+					   NAMEDATALEN - 1));
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must not contain non-ASCII characters"));
+}
+
+/*
+ * As above, but for the GUC check_hook. We allow the empty string here,
+ * though, as equivalent to disabling the feature.
+ */
+static bool
+pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source)
+{
+	char	   *stash_name = *newval;
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash names may not be longer than %d bytes",
+							NAMEDATALEN - 1);
+		return false;
+	}
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must not contain non-ASCII characters");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Create an advice stash.
+ */
+static void
+pgsa_create_stash(char *stash_name)
+{
+	pgsa_stash *stash;
+	bool		found;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Create a stash with this name, unless one already exists. */
+	stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found);
+	if (found)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" already exists", stash_name));
+	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+}
+
+/*
+ * Remove any stored advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_clear_advice_string(char *stash_name, int64 queryId)
+{
+	pgsa_entry *entry;
+	pgsa_entry_key key;
+	int			stash_id;
+	dsa_pointer old_dp;
+
+	/* Translate the stash name to an integer ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/*
+	 * Look for an existing entry, and free it. But, be sure to save the
+	 * pointer to the associated advice string, if any.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		old_dp = InvalidDsaPointer;
+	else
+	{
+		old_dp = entry->advice_string;
+		dshash_delete_entry(pgsa_entry_dshash, entry);
+	}
+
+	/* Now we free the advice string as well, if there was one. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
+
+/*
+ * Drop an advice stash.
+ */
+static void
+pgsa_drop_stash(char *stash_name)
+{
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	dshash_seq_status iterator;
+	int			stash_id;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove the entry for this advice stash. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, true);
+	if (stash == NULL)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+	stash_id = stash->pgsa_stash_id;
+	dshash_delete_entry(pgsa_stash_dshash, stash);
+
+	/*
+	 * It should now be impossible for any new entries to be added for the
+	 * advice stash we just deleted. Go through and clean out all the existing
+	 * ones.
+	 */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		if (stash_id == entry->key.pgsa_stash_id)
+		{
+			if (entry->advice_string != InvalidDsaPointer)
+				dsa_free(pgsa_dsa_area, entry->advice_string);
+			dshash_delete_current(&iterator);
+		}
+	}
+	dshash_seq_term(&iterator);
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgsa_init_shared_state(void *ptr, void *arg)
+{
+	pgsa_shared_state *state = (pgsa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_stash_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
+	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
+	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
+	state->next_stash_id = 1;
+	state->area = DSA_HANDLE_INVALID;
+	state->stash_hash = DSHASH_HANDLE_INVALID;
+	state->entry_hash = DSHASH_HANDLE_INVALID;
+}
+
+/*
+ * Look up the integer ID that corresponds to the given stash name.
+ *
+ * Returns 0 if no such stash exists.
+ */
+static int
+pgsa_lookup_stash_id(char *stash_name)
+{
+	pgsa_stash *stash;
+	int			stash_id;
+
+	/* Search the shared hash table. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, false);
+	if (stash == NULL)
+		return 0;
+	stash_id = stash->pgsa_stash_id;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	return stash_id;
+}
+
+/*
+ * Store a new or updated advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
+{
+	pgsa_entry *entry;
+	bool		found;
+	pgsa_entry_key key;
+	int			stash_id;
+	dsa_pointer new_dp;
+	dsa_pointer old_dp;
+
+	/* Translate the stash name to an integer ID. */
+restart:
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/* Make sure that an entry exists. */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find_or_insert(pgsa_entry_dshash, &key, &found);
+	if (!found)
+		entry->advice_string = InvalidDsaPointer;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/*
+	 * Copy the advice string into dynamic shared memory.
+	 *
+	 * If we fail after this point, we'll have a server-lifespan memory leak.
+	 * We assume that, having created the entry above, we'll be able to find
+	 * it again without an error.
+	 */
+	new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1);
+	strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string);
+
+	/*
+	 * Refind the entry and swap the new pointer into place.
+	 *
+	 * If the entry has been deleted since we found or created it above, free
+	 * memory and retry from the top.
+	 */
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+	{
+		dsa_free(pgsa_dsa_area, new_dp);
+		goto restart;
+	}
+	old_dp = entry->advice_string;
+	entry->advice_string = new_dp;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we replaced an old advice string, free it. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control
new file mode 100644
index 00000000000..4a0fff5c866
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.control
@@ -0,0 +1,5 @@
+# pg_stash_advice extension
+comment = 'store and automatically apply plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_stash_advice'
+relocatable = true
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
new file mode 100644
index 00000000000..aed2d2a5a9a
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
@@ -0,0 +1,130 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+SET pg_stash_advice.stash_name = 'regress_stash';
+
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('regress_empty_stash');
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index 2ab6fafbab1..8f09d728698 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -160,6 +160,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 407ff3abffe..8c14bab84e9 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -144,6 +144,7 @@
 <!ENTITY oid2name        SYSTEM "oid2name.sgml">
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
+<!ENTITY pgstashadvice   SYSTEM "pgstashadvice.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
 <!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
new file mode 100644
index 00000000000..089fc66446f
--- /dev/null
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -0,0 +1,218 @@
+<!-- doc/src/sgml/pgstashadvice.sgml -->
+
+<sect1 id="pgstashadvice" xreflabel="pg_stash_advice">
+ <title>pg_stash_advice &mdash; store and automatically apply plan advice</title>
+
+ <indexterm zone="pgstashadvice">
+  <primary>pg_stash_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_stash_advice</filename> extension allows you to stash
+  <link linkend="pgplanadvice">plan advice</link> strings in dynamic
+  shared memory where they can be automatically applied. An
+  <literal>advice stash</literal> is a mapping from
+  <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
+  strings. Whenever a session is asked to plan a query whose query ID appears
+  in the relevant advice stash, the plan advice string is automatically applied
+  to guide planning. Note that advice stashes exist purely in memory. This
+  means both that it is important to be mindful of memory consumption when
+  deciding how much plan advice to stash, and also that advice stashes must
+  be recreated and repopulated whenever the server is restarted.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
+  one database, so that you have access to the SQL functions to manage
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
+  module to be loaded in all sessions where you want this module to
+  automatically apply advice. It will usually be best to do this by adding
+  <literal>pg_stash_advice</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> and restarting the server.
+ </para>
+
+ <para>
+  Once you have met the above criteria, you can create advice stashes
+  using the <literal>pg_create_advice_stash</literal> function described
+  below and set the plan advice for a given query ID in a given stash using
+  the <literal>pg_set_stashed_advice</literal> function. Then, you need
+  only configure <literal>pg_stash_advice.stash_name</literal> to point
+  to the chosen advice stash name. For some use cases, rather than setting
+  this on a system-wide basis, you may find it helpful to use
+  <literal>ALTER DATABASE ... SET</literal> or
+  <literal>ALTER ROLE ... SET</literal> to configure values that will apply
+  only to a database or only to a certain role. Likewise, it may sometimes
+  be better to set the stash name in a particular session using
+  <literal>SET</literal>.
+ </para>
+
+ <para>
+  Because <literal>pg_stash_advice</literal> works on the basis of query
+  identifiers, you will need to determine the query identifier for each query
+  whose plan you wish to control. You will also need to determine the advice
+  string that you wish to store for each query. One way to do this is to use
+  <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
+  show the query ID, and the <literal>PLAN_ADVICE</literal> option will
+  show plan advice. <xref linkend="pgcollectadvice" /> can be used to
+  obtain this information for an entire workload, although care must be
+  taken since it can use up a lot of memory very quickly. Query identifiers can
+  also be obtained through tools such as <xref linkend="pgstatstatements" />
+  or <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
+  will not provide plan advice strings. Note that
+  <xref linkend="guc-compute-query-id" /> must be enabled for query
+  identifiers to be computed; if set to <literal>auto</literal>, loading
+  <literal>pg_stash_advice</literal> will enable it automatically.
+ </para>
+
+ <para>
+  Generally, the fact that the planner is able to change query plans as
+  the underlying distribution of data changes is a feature, not a bug.
+  Moreover, applying plan advice can have a noticeable performance cost even
+  when it does not result in a change to the query plan. Therefore, it is
+  a good idea to use this feature only when and to the extent needed.
+  Plan advice strings can be trimmed down to mention only those aspects
+  of the plan that need to be controlled, and used only for queries where
+  there is believed to be a significant risk of planner error.
+ </para>
+
+ <para>
+  Note that <literal>pg_stash_advice</literal> currently lacks a sophisticated
+  security model. Only the superuser, or a user to whom the superuser has
+  granted <literal>EXECUTE</literal> permission on the relevant functions,
+  may create advice stashes or alter their contents, but any user may set
+  <literal>pg_stash_advice.stash_name</literal> for their session, and this
+  may reveal the contents of any advice stash with that name. Users should
+  assume that information embedded in stashed advice strings may become visible
+  to nonprivileged users.
+ </para>
+
+ <sect2 id="pgstashadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_create_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_create_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Creates a new, empty advice stash with the given name.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_drop_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_drop_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Drops the named advice stash and all of its entries.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_set_stashed_advice(stash_name text, query_id bigint,
+       advice_string text) returns void</function>
+     <indexterm>
+      <primary>pg_set_stashed_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Stores an advice string in the named advice stash, associated with
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
+      <parameter>advice_string</parameter> is <literal>NULL</literal>,
+      any existing entry for that query identifier is removed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stashes() returns setof (stash_name text,
+       num_entries bigint)</function>
+     <indexterm>
+      <primary>pg_get_advice_stashes</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each advice stash, showing the stash name and
+      the number of entries it contains.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stash_contents(stash_name text) returns setof
+       (stash_name text, query_id bigint, advice_string text)</function>
+     <indexterm>
+      <primary>pg_get_advice_stash_contents</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each entry in the named advice stash. If
+      <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
+      entries from all stashes.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.stash_name</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the name of the advice stash to consult during query
+      planning. The default value is the empty string, which disables
+      this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b260f979e64..44cac5b4cb6 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4023,6 +4023,12 @@ pgpa_trove_lookup_type
 pgpa_trove_result
 pgpa_trove_slice
 pgpa_unrolled_join
+pgsa_entry
+pgsa_entry_key
+pgsa_shared_state
+pgsa_stash
+pgsa_stash_count
+pgsa_stash_name
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



  [application/octet-stream] v19-0003-Test-pg_plan_advice-using-a-new-test_plan_advice.patch (10.5K, 3-v19-0003-Test-pg_plan_advice-using-a-new-test_plan_advice.patch)
  download | inline diff:
From e97d71be36813b8fa34c25d7b25a54c7c1afecb0 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Sat, 7 Feb 2026 09:36:08 -0500
Subject: [PATCH v19 3/4] Test pg_plan_advice using a new test_plan_advice
 module.

The TAP test included in this new module runs the regression tests
with pg_plan_advice loaded. It arranges for each query to be planned
twice.  The first time, we generate plan advice. The second time, we
replan the query using the resulting advice string. If the tests
fail, that means that using pg_plan_advice to tell the planner to
do what it was going to do anyway breaks something, which indicates
a problem either with pg_plan_advice or with the planner.

The test also enables pg_plan_advice.feedback_warnings, so that if the
plan advice fails to apply cleanly when the query is replanned, a
failure will occur.
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 src/test/modules/test_plan_advice/Makefile    |  28 ++++
 src/test/modules/test_plan_advice/meson.build |  29 ++++
 .../test_plan_advice/t/001_replan_regress.pl  |  65 ++++++++
 .../test_plan_advice/test_plan_advice.c       | 143 ++++++++++++++++++
 6 files changed, 267 insertions(+)
 create mode 100644 src/test/modules/test_plan_advice/Makefile
 create mode 100644 src/test/modules/test_plan_advice/meson.build
 create mode 100644 src/test/modules/test_plan_advice/t/001_replan_regress.pl
 create mode 100644 src/test/modules/test_plan_advice/test_plan_advice.c

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 4ac5c84db43..a1540269cf5 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -39,6 +39,7 @@ SUBDIRS = \
 		  test_oat_hooks \
 		  test_parser \
 		  test_pg_dump \
+		  test_plan_advice \
 		  test_predtest \
 		  test_radixtree \
 		  test_rbtree \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index e2b3eef4136..7c052803c98 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -40,6 +40,7 @@ subdir('test_misc')
 subdir('test_oat_hooks')
 subdir('test_parser')
 subdir('test_pg_dump')
+subdir('test_plan_advice')
 subdir('test_predtest')
 subdir('test_radixtree')
 subdir('test_rbtree')
diff --git a/src/test/modules/test_plan_advice/Makefile b/src/test/modules/test_plan_advice/Makefile
new file mode 100644
index 00000000000..be026ce34bf
--- /dev/null
+++ b/src/test/modules/test_plan_advice/Makefile
@@ -0,0 +1,28 @@
+# src/test/modules/test_plan_advice/Makefile
+
+PGFILEDESC = "test_plan_advice - test whether generated plan advice works"
+
+MODULE_big = test_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	test_plan_advice.o
+
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_plan_advice
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+override CPPFLAGS += -I$(top_srcdir)/contrib/pg_plan_advice
+
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
diff --git a/src/test/modules/test_plan_advice/meson.build b/src/test/modules/test_plan_advice/meson.build
new file mode 100644
index 00000000000..afde420baed
--- /dev/null
+++ b/src/test/modules/test_plan_advice/meson.build
@@ -0,0 +1,29 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+test_plan_advice_sources = files(
+  'test_plan_advice.c',
+)
+
+if host_system == 'windows'
+  test_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_plan_advice',
+    '--FILEDESC', 'test_plan_advice - test whether generated plan advice works',])
+endif
+
+test_plan_advice = shared_module('test_plan_advice',
+  test_plan_advice_sources,
+  include_directories: pg_plan_advice_inc,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_plan_advice
+
+tests += {
+  'name': 'test_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_replan_regress.pl',
+    ],
+  },
+}
diff --git a/src/test/modules/test_plan_advice/t/001_replan_regress.pl b/src/test/modules/test_plan_advice/t/001_replan_regress.pl
new file mode 100644
index 00000000000..38ffa4d11ae
--- /dev/null
+++ b/src/test/modules/test_plan_advice/t/001_replan_regress.pl
@@ -0,0 +1,65 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_plan_advice to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+$node->append_conf('postgresql.conf', <<EOM);
+shared_preload_libraries='test_plan_advice'
+pg_plan_advice.always_explain_supplied_advice=false
+pg_plan_advice.feedback_warnings=true
+EOM
+$node->start;
+
+my $srcdir = abs_path("../../../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+done_testing();
diff --git a/src/test/modules/test_plan_advice/test_plan_advice.c b/src/test/modules/test_plan_advice/test_plan_advice.c
new file mode 100644
index 00000000000..cff5039b5c8
--- /dev/null
+++ b/src/test/modules/test_plan_advice/test_plan_advice.c
@@ -0,0 +1,143 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_plan_advice.c
+ *	  Test pg_plan_advice by planning every query with generated advice.
+ *
+ * With this module loaded, every time a query is executed, we end up
+ * planning it twice. The first time we plan it, we generate plan advice,
+ * which we then feed back to pg_plan_advice as the supplied plan advice.
+ * It is then planned a second time using that advice. This hopefully
+ * allows us to detect cases where the advice is incorrect or causes
+ * failures or plan changes for some reason.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  src/test/modules/test_plan_advice/test_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/xact.h"
+#include "fmgr.h"
+#include "optimizer/optimizer.h"
+#include "pg_plan_advice.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static bool in_recursion = false;
+
+static char *test_plan_advice_advisor(PlannerGlobal *glob,
+									  Query *parse,
+									  const char *query_string,
+									  int cursorOptions,
+									  ExplainState *es);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/*
+	 * Ask pg_plan_advice to get advice strings from test_plan_advice_advisor
+	 */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+
+	(*add_advisor_fn) (test_plan_advice_advisor);
+}
+
+/*
+ * Re-plan the given query and return the generated advice string as the
+ * supplied advice.
+ */
+static char *
+test_plan_advice_advisor(PlannerGlobal *glob, Query *parse,
+						 const char *query_string, int cursorOptions,
+						 ExplainState *es)
+{
+	PlannedStmt *pstmt;
+	int			save_nestlevel = 0;
+	DefElem    *pgpa_item;
+	DefElem    *advice_string_item;
+
+	/*
+	 * Since this function is called from the planner and triggers planning,
+	 * we need a recursion guard.
+	 */
+	if (in_recursion)
+		return NULL;
+
+	PG_TRY();
+	{
+		in_recursion = true;
+
+		/*
+		 * Planning can trigger expression evaluation, which can result in
+		 * sending NOTICE messages or other output to the client. To avoid
+		 * that, we set client_min_messages = ERROR in the hopes of getting
+		 * the same output with and without this module.
+		 *
+		 * We also need to set pg_plan_advice.always_store_advice_details so
+		 * that pg_plan_advice will generate an advice string, since the whole
+		 * point of this function is to get access to that.
+		 */
+		save_nestlevel = NewGUCNestLevel();
+		set_config_option("client_min_messages", "error",
+						  PGC_SUSET, PGC_S_SESSION,
+						  GUC_ACTION_SAVE, true, 0, false);
+		set_config_option("pg_plan_advice.always_store_advice_details", "true",
+						  PGC_SUSET, PGC_S_SESSION,
+						  GUC_ACTION_SAVE, true, 0, false);
+
+		/*
+		 * Replan. We must copy the Query, because the planner modifies it.
+		 * (As noted elsewhere, that's unfortunate; perhaps it will be fixed
+		 * some day.)
+		 */
+		pstmt = planner(copyObject(parse), query_string, cursorOptions,
+						glob->boundParams, es);
+	}
+	PG_FINALLY();
+	{
+		in_recursion = false;
+	}
+	PG_END_TRY();
+
+	/* Roll back any GUC changes */
+	if (save_nestlevel > 0)
+		AtEOXact_GUC(false, save_nestlevel);
+
+	/* Extract and return the advice string */
+	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
+										"pg_plan_advice");
+	if (pgpa_item == NULL)
+		elog(ERROR, "extension state for pg_plan_advice not found");
+	advice_string_item = find_defelem_by_defname((List *) pgpa_item->arg,
+												 "advice_string");
+	if (advice_string_item == NULL)
+		elog(ERROR,
+			 "advice string for pg_plan_advice not found in extension state");
+	return strVal(advice_string_item->arg);
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
-- 
2.51.0



  [application/octet-stream] v19-0002-Add-pg_collect_advice-contrib-module.patch (56.1K, 4-v19-0002-Add-pg_collect_advice-contrib-module.patch)
  download | inline diff:
From 7f089ec1d56960e68646960375475e78db624856 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Thu, 26 Feb 2026 16:51:16 -0500
Subject: [PATCH v19 2/4] Add pg_collect_advice contrib module.

This module allows automated, large-scale collection of queries and
the associated plan advice strings using either backend-local memory
or dynamic shared memory. In either case, memory usage can be limited
by restriction the maximum number of queries and advice strings
stored. Care should be taken with these values, and with the use of
this module in general, because it's easy to chew up an unreasonably
large amount of memory. Unlike pg_stat_statements, this module does
not provide for query normalization or even deduplication; it simply
makes a record for every query planned.

It can be useful to enable query ID computaton before using the
module, but it's not required. If not done, all queries will simply
show a query ID of zero.

Reviewed-by: Alexandra Wang <[email protected]> (earlier version)
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_collect_advice/Makefile            |  29 +
 contrib/pg_collect_advice/collector.c         | 641 ++++++++++++++++++
 .../expected/local_collector.out              |  69 ++
 contrib/pg_collect_advice/interface.c         | 303 +++++++++
 contrib/pg_collect_advice/meson.build         |  41 ++
 .../pg_collect_advice--1.0.sql                |  43 ++
 .../pg_collect_advice.control                 |   5 +
 contrib/pg_collect_advice/pg_collect_advice.h |  39 ++
 .../pg_collect_advice/sql/local_collector.sql |  46 ++
 contrib/pg_collect_advice/t/001_regress.pl    | 151 +++++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgcollectadvice.sgml             | 244 +++++++
 src/tools/pgindent/typedefs.list              |   6 +
 16 files changed, 1621 insertions(+)
 create mode 100644 contrib/pg_collect_advice/Makefile
 create mode 100644 contrib/pg_collect_advice/collector.c
 create mode 100644 contrib/pg_collect_advice/expected/local_collector.out
 create mode 100644 contrib/pg_collect_advice/interface.c
 create mode 100644 contrib/pg_collect_advice/meson.build
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice--1.0.sql
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.control
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.h
 create mode 100644 contrib/pg_collect_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_collect_advice/t/001_regress.pl
 create mode 100644 doc/src/sgml/pgcollectadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index dd04c20acd2..22071034e51 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -31,6 +31,7 @@ SUBDIRS = \
 		pageinspect	\
 		passwordcheck	\
 		pg_buffercache	\
+		pg_collect_advice \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
diff --git a/contrib/meson.build b/contrib/meson.build
index 5a752eac347..ff422d9b7fc 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -45,6 +45,7 @@ subdir('pageinspect')
 subdir('passwordcheck')
 subdir('pg_buffercache')
 subdir('pgcrypto')
+subdir('pg_collect_advice')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
diff --git a/contrib/pg_collect_advice/Makefile b/contrib/pg_collect_advice/Makefile
new file mode 100644
index 00000000000..33f715606f9
--- /dev/null
+++ b/contrib/pg_collect_advice/Makefile
@@ -0,0 +1,29 @@
+# contrib/pg_collect_advice/Makefile
+
+MODULE_big = pg_collect_advice
+OBJS = \
+	$(WIN32RES) \
+	collector.o \
+	interface.o
+
+EXTENSION = pg_collect_advice
+DATA = pg_collect_advice--1.0.sql
+PGFILEDESC = "pg_collect_advice - collect queries and their plan advice strings"
+
+REGRESS = local_collector
+TAP_TESTS = 1
+
+# required for 001_regress.pl
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_collect_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_collect_advice/collector.c b/contrib/pg_collect_advice/collector.c
new file mode 100644
index 00000000000..053a22245b2
--- /dev/null
+++ b/contrib/pg_collect_advice/collector.c
@@ -0,0 +1,641 @@
+/*-------------------------------------------------------------------------
+ *
+ * collector.c
+ *	  workhorse for saving plan advice in backend-local or shared memory
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgca_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgca_collected_advice;
+
+/*
+ * A bunch of pointers to pgca_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgca_local_advice_chunk
+{
+	pgca_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgca_local_advice_chunk;
+
+/*
+ * Information about all of the pgca_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgca_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgca_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgca_local_advice_chunk **chunks;
+} pgca_local_advice;
+
+/*
+ * Just like pgca_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgca_shared_advice_chunk;
+
+/*
+ * Just like pgca_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgca_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgca_local_advice *local_collector = NULL;
+static pgca_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgca_collected_advice *make_collected_advice(Oid userid,
+													Oid dbid,
+													uint64 queryId,
+													TimestampTz timestamp,
+													const char *query_string,
+													const char *advice_string,
+													dsa_area *area,
+													dsa_pointer *result);
+static void store_local_advice(pgca_collected_advice *ca);
+static void trim_local_advice(int limit);
+static void store_shared_advice(dsa_pointer ca_pointer);
+static void trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgca_collected_advice */
+static inline const char *
+query_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgca_collected_advice */
+static inline const char *
+advice_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pg_collect_advice_save(uint64 queryId, const char *query_string,
+					   const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_collect_advice_local_collector &&
+		pg_collect_advice_local_collection_limit > 0)
+	{
+		pgca_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+		ca = make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string,
+								   NULL, NULL);
+		store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_collect_advice_shared_collector &&
+		pg_collect_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_collect_advice_dsa_area();
+		dsa_pointer ca_pointer = InvalidDsaPointer; /* placate compiler */
+
+		make_collected_advice(userid, dbid, queryId, now,
+							  query_string, advice_string, area,
+							  &ca_pointer);
+		store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgca_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgca_collected_advice *
+make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+					  TimestampTz timestamp,
+					  const char *query_string,
+					  const char *advice_string,
+					  dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgca_collected_advice *ca;
+
+	total_length = offsetof(pgca_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = userid;
+	ca->dbid = dbid;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pgca_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+store_local_advice(pgca_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgca_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgca_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgca_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgca_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_local_advice(pg_collect_advice_local_collection_limit);
+}
+
+/*
+ * Add a pgca_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_collect_advice DSA area
+ * and should point to an object of type pgca_collected_advice.
+ */
+static void
+store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	pgca_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgca_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgca_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgca_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_shared_advice(area, pg_collect_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+trim_local_advice(int limit)
+{
+	pgca_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgca_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgca_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+trim_shared_advice(dsa_area *area, int limit)
+{
+	pgca_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(dsa_pointer) * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in shared memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgca_shared_advice *sa = shared_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_collect_advice/expected/local_collector.out b/contrib/pg_collect_advice/expected/local_collector.out
new file mode 100644
index 00000000000..f57b96ee835
--- /dev/null
+++ b/contrib/pg_collect_advice/expected/local_collector.out
@@ -0,0 +1,69 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_collect_advice/interface.c b/contrib/pg_collect_advice/interface.c
new file mode 100644
index 00000000000..feb11974152
--- /dev/null
+++ b/contrib/pg_collect_advice/interface.c
@@ -0,0 +1,303 @@
+/*-------------------------------------------------------------------------
+ *
+ * interface.c
+ *	  interface routines for the plan advice collector
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/interface.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+/* Shared memory pointers */
+static pgca_shared_state *pgca_state = NULL;
+static dsa_area *pgca_dsa_area = NULL;
+
+/* GUC variables */
+bool		pg_collect_advice_local_collector = false;
+int			pg_collect_advice_local_collection_limit = 0;
+bool		pg_collect_advice_shared_collector = false;
+int			pg_collect_advice_shared_collection_limit = 0;
+
+/* Shadow variables for GUC assign hooks */
+static bool pg_collect_advice_local_collector_as_assigned = false;
+static bool pg_collect_advice_shared_collector_as_assigned = false;
+
+/* Other file-level globals */
+static void (*request_advice_generation_fn) (bool activate) = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+static MemoryContext pgca_memory_context = NULL;
+
+/* Function prototypes */
+static void pgca_init_shared_state(void *ptr, void *arg);
+static void pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string,
+								  PlannedStmt *pstmt);
+static void pg_collect_advice_local_collector_assign_hook(bool newval,
+														  void *extra);
+static void pg_collect_advice_shared_collector_assign_hook(bool newval,
+														   void *extra);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	/*
+	 * Get a pointer so we can call pg_plan_advice_request_advice_generation.
+	 *
+	 * We need to do this before defining custom GUCs; otherwise, our assign
+	 * hook will try to use this function pointer before it's initialized.
+	 *
+	 * We also need to do this before installing our own hooks, so that if
+	 * pg_plan_advice is not yet loaded, it will install its hooks before we
+	 * install ours. (See comments in pgca_planner_shutdown.)
+	 */
+	request_advice_generation_fn =
+		load_external_function("pg_plan_advice",
+							   "pg_plan_advice_request_advice_generation",
+							   true, NULL);
+
+	/* Define our GUCs. */
+	DefineCustomBoolVariable("pg_collect_advice.local_collector",
+							 "Enable the local advice collector.",
+							 NULL,
+							 &pg_collect_advice_local_collector,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_local_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_collect_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomBoolVariable("pg_collect_advice.shared_collector",
+							 "Enable the shared advice collector.",
+							 NULL,
+							 &pg_collect_advice_shared_collector,
+							 false,
+							 PGC_SUSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_shared_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_collect_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_collect_advice");
+
+	/* Install hooks */
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgca_planner_shutdown;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgca_init_shared_state(void *ptr, void *arg)
+{
+	pgca_shared_state *state = (pgca_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_collect_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_collect_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_collect_advice_get_mcxt(void)
+{
+	if (pgca_memory_context == NULL)
+		pgca_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_collect_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgca_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ */
+pgca_shared_state *
+pg_collect_advice_attach(void)
+{
+	if (pgca_state == NULL)
+	{
+		bool		found;
+
+		pgca_state =
+			GetNamedDSMSegment("pg_collect_advice", sizeof(pgca_shared_state),
+							   pgca_init_shared_state, &found, NULL);
+	}
+
+	return pgca_state;
+}
+
+/*
+ * Return a pointer to pg_collect_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_collect_advice_dsa_area(void)
+{
+	if (pgca_dsa_area == NULL)
+	{
+		pgca_shared_state *state = pg_collect_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgca_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgca_dsa_area);
+			state->area = dsa_get_handle(pgca_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgca_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgca_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgca_dsa_area;
+}
+
+/*
+ * After planning is complete, retrieve the advice string, if present, and
+ * pass it through to the collector.
+ */
+static void
+pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	DefElem    *pgpa_item;
+	DefElem    *advice_string_item;
+	char	   *advice_string;
+
+	/*
+	 * Pass call to previous hook.
+	 *
+	 * We want to be called after pg_plan_advice's shutdown hook has already
+	 * executed. Our _PG_init() makes sure that pg_plan_advice's hooks are
+	 * always loaded before ours, and here we pass the hook call down first,
+	 * before doing our own work. The combination of those two things should
+	 * be good enough to ensure that the advice string is already present when
+	 * we go looking for it.
+	 */
+	if (prev_planner_shutdown)
+		(*prev_planner_shutdown) (glob, parse, query_string, pstmt);
+
+	/* Fish out the advice string. If not found, do nothing. */
+	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
+										"pg_plan_advice");
+	if (pgpa_item == NULL)
+		return;
+	advice_string_item = find_defelem_by_defname((List *) pgpa_item->arg,
+												 "advice_string");
+	if (advice_string_item == NULL)
+		return;
+	advice_string = strVal(advice_string_item->arg);
+
+	/*
+	 * Pass it through to the actual collector. But, if it's the empty string,
+	 * we assume that collecting it is uninteresting.
+	 */
+	if (advice_string[0] != '\0')
+		pg_collect_advice_save(pstmt->queryId, query_string, advice_string);
+}
+
+/*
+ * pgca_planner_shutdown won't find any advice to collect unless we've
+ * requested that it be generated. So, whenever the effective value of
+ * pg_collect_advice.local_collector changes, either make or
+ * revoke a request for advice generation.
+ */
+static void
+pg_collect_advice_local_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_local_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_local_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_local_collector_as_assigned = newval;
+}
+
+/*
+ * Same as above, but for pg_collect_advice.shared_collector
+ */
+static void
+pg_collect_advice_shared_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_shared_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_shared_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_shared_collector_as_assigned = newval;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_collect_advice/meson.build b/contrib/pg_collect_advice/meson.build
new file mode 100644
index 00000000000..ca7d5ecff1a
--- /dev/null
+++ b/contrib/pg_collect_advice/meson.build
@@ -0,0 +1,41 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_collect_advice_sources = files(
+  'collector.c',
+  'interface.c',
+)
+
+if host_system == 'windows'
+  pg_collect_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_collect_advice',
+    '--FILEDESC', 'pg_collect_advice - collect queries and their plan advice strings',])
+endif
+
+pg_collect_advice = shared_module('pg_collect_advice',
+  pg_collect_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_collect_advice
+
+install_data(
+  'pg_collect_advice--1.0.sql',
+  'pg_collect_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_collect_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'local_collector',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_regress.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_collect_advice/pg_collect_advice--1.0.sql b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
new file mode 100644
index 00000000000..0be86c54fc1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_collect_advice/pg_collect_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_collect_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_clear_collected_shared_advice() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_collect_advice/pg_collect_advice.control b/contrib/pg_collect_advice/pg_collect_advice.control
new file mode 100644
index 00000000000..601e5e24ea1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.control
@@ -0,0 +1,5 @@
+# pg_collect_advice extension
+comment = 'collect queries and the associated plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_collect_advice'
+relocatable = true
diff --git a/contrib/pg_collect_advice/pg_collect_advice.h b/contrib/pg_collect_advice/pg_collect_advice.h
new file mode 100644
index 00000000000..480c2c633c4
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.h
@@ -0,0 +1,39 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_collect_advice.h
+ *	  definitions and declarations for pg_collect_advice module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/pg_collect_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_COLLECT_ADVICE_H
+#define PG_COLLECT_ADVICE_H
+
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgca_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgca_shared_state;
+
+/* GUC variables */
+extern bool pg_collect_advice_local_collector;
+extern int	pg_collect_advice_local_collection_limit;
+extern bool pg_collect_advice_shared_collector;
+extern int	pg_collect_advice_shared_collection_limit;
+
+/* Function prototypes */
+extern MemoryContext pg_collect_advice_get_mcxt(void);
+extern pgca_shared_state *pg_collect_advice_attach(void);
+extern dsa_area *pg_collect_advice_dsa_area(void);
+extern void pg_collect_advice_save(uint64 queryId, const char *query_string,
+								   const char *advice_string);
+
+#endif
diff --git a/contrib/pg_collect_advice/sql/local_collector.sql b/contrib/pg_collect_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..41b187c5375
--- /dev/null
+++ b/contrib/pg_collect_advice/sql/local_collector.sql
@@ -0,0 +1,46 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_collect_advice/t/001_regress.pl b/contrib/pg_collect_advice/t/001_regress.pl
new file mode 100644
index 00000000000..ed934d0c859
--- /dev/null
+++ b/contrib/pg_collect_advice/t/001_regress.pl
@@ -0,0 +1,151 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_collect_advice and pg_plan_advice
+# to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+#
+# We run with pg_collect_advice.shared_collection_limit set to ensure that the
+# plan tree walker code runs against every query in the regression tests. If
+# we're unable to properly analyze any of those plan trees, this test should
+# hopefully fail.
+#
+# We set pg_collect_advice.advice to an advice string that will cause the advice
+# trove to be populated with a few entries of various sorts, but which we do
+# not expect to match anything in the regression test queries. This way, the
+# planner hooks will be called, improving code coverage, but no plans should
+# actually change.
+#
+# pg_plan_advice.always_explain_supplied_advice=false is needed to avoid
+# breaking regression test queries that use EXPLAIN. In the real world, it
+# seems like users will want EXPLAIN output to show supplied advice so that
+# it's clear whether normal planner behavior has been altered, but here that's
+# undesirable.
+$node->append_conf('postgresql.conf', <<EOM);
+shared_preload_libraries=pg_collect_advice
+pg_collect_advice.shared_collection_limit=1000000
+pg_collect_advice.shared_collector=true
+pg_plan_advice.advice='SEQ_SCAN(entirely_fictitious) HASH_JOIN(total_fabrication) GATHER(completely_imaginary)'
+pg_plan_advice.always_explain_supplied_advice=false
+EOM
+$node->start;
+
+my $srcdir = abs_path("../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+# Create the extension so we can access the collector
+$node->safe_psql('postgres', 'CREATE EXTENSION pg_collect_advice');
+
+# Verify that a large amount of advice was collected
+my $all_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice();
+EOM
+cmp_ok($all_query_count, '>', 20000, "copious advice collected");
+
+# Verify that lots of different advice strings were collected
+my $distinct_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM
+	(SELECT DISTINCT advice FROM pg_get_collected_shared_advice());
+EOM
+cmp_ok($distinct_query_count, '>', 3000, "diverse advice collected");
+
+# We want to test for the presence of our known tags in the collected advice.
+# Put all tags into the hash that follows; map any tags that aren't tested
+# by the core regression tests to 0, and others to 1.
+my %tag_map = (
+	BITMAP_HEAP_SCAN => 1,
+	FOREIGN_JOIN => 0,
+	GATHER => 1,
+	GATHER_MERGE => 1,
+	HASH_JOIN => 1,
+	INDEX_ONLY_SCAN => 1,
+	INDEX_SCAN => 1,
+	JOIN_ORDER => 1,
+	MERGE_JOIN_MATERIALIZE => 1,
+	MERGE_JOIN_PLAIN => 1,
+	NESTED_LOOP_MATERIALIZE => 1,
+	NESTED_LOOP_MEMOIZE => 1,
+	NESTED_LOOP_PLAIN => 1,
+	NO_GATHER => 1,
+	PARTITIONWISE => 1,
+	SEMIJOIN_NON_UNIQUE => 1,
+	SEMIJOIN_UNIQUE => 1,
+	SEQ_SCAN => 1,
+	TID_SCAN => 1,
+);
+for my $tag (sort keys %tag_map)
+{
+	my $checkit = $tag_map{$tag};
+
+	# Search for the given tag. This is not entirely robust: it could get thrown
+	# off by a table alias such as "FOREIGN_JOIN(", but that probably won't
+	# happen in the core regression tests.
+	my $tag_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice()
+	WHERE advice LIKE '%$tag(%'
+EOM
+
+	# Check that the tag got a non-trivial amount of use, unless told otherwise.
+	cmp_ok($tag_count, '>', 10, "multiple uses of $tag") if $checkit;
+
+	# Regardless, note the exact count in the log, for human consumption.
+	note("found $tag_count advice strings containing $tag");
+}
+
+# Trigger a partial cleanup of the shared advice collector, and then a full
+# cleanup.
+$node->safe_psql('postgres', <<EOM);
+SET pg_collect_advice.shared_collection_limit=500;
+SELECT * FROM pg_clear_collected_shared_advice();
+EOM
+
+done_testing();
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index bdd4865f53f..2ab6fafbab1 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -152,6 +152,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pageinspect;
  &passwordcheck;
  &pgbuffercache;
+ &pgcollectadvice;
  &pgcrypto;
  &pgfreespacemap;
  &pglogicalinspect;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index d90b4338d2a..407ff3abffe 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -145,6 +145,7 @@
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
+<!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
 <!ENTITY pglogicalinspect  SYSTEM "pglogicalinspect.sgml">
diff --git a/doc/src/sgml/pgcollectadvice.sgml b/doc/src/sgml/pgcollectadvice.sgml
new file mode 100644
index 00000000000..220aabe78c6
--- /dev/null
+++ b/doc/src/sgml/pgcollectadvice.sgml
@@ -0,0 +1,244 @@
+<!-- doc/src/sgml/pgcollectadvice.sgml -->
+
+<sect1 id="pgcollectadvice" xreflabel="pg_collect_advice">
+ <title>pg_collect_advice &mdash; collect queries and their plan advice strings</title>
+
+ <indexterm zone="pgcollectadvice">
+  <primary>pg_collect_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_collect_advice</filename> extension allows you to
+  automatically generate plan advice each time a query is planned and store
+  the query and the generated advice string either in local or shared memory.
+  Note that this extension requires the <xref linkend="pgplanadvice" /> module,
+  which performs the actual plan advice generation; this module only knows
+  how to store the generated advice for later examination. Whenever
+  <literal>pg_collect_advice</literal> is loaded, it will automatically load
+  <literal>pg_plan_advice</literal>.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_collect_advice</literal> in at least
+  one database, so that you have a way to examine the collected advice.
+  You will also need the <literal>pg_collect_advice</literal> module
+  to be loaded in all sessions where advice is to be collected. It will
+  usually be best to do this by adding <literal>pg_collect_advice</literal>
+  to <xref linkend="guc-shared-preload-libraries"/> and restarting the
+  server.
+ </para>
+
+ <para>
+  <literal>pg_collect_advice</literal> includes both a shared advice
+  collector and a local advice collector. The local advice collector makes
+  queries and their advice strings visible only to the session where those
+  queries were planned, while the shared advice collector collects data
+  on a system-wide basis, and authorized users can examine data from all
+  sessions.
+ </para>
+
+ <para>
+  To enable a collector, you must first set a collection limit. When the
+  number of queries for which advice has been stored exceeds the collection
+  limit, the oldest queries and the corresponding advice will be discarded.
+  Then, you must adjust a separate setting to actually enable advice
+  collection. For the local collector, set the collection limit by configuring
+  <literal>pg_collect_advice.local_collection_limit</literal> to a value
+  greater than zero, and then enable advice collection by setting
+  <literal>pg_collect_advice.local_collector = true</literal>. For the shared
+  collector, the procedure is the same, except that the names of the settings
+  are <literal>pg_collect_advice.shared_collection_limit</literal> and
+  <literal>pg_collect_advice.shared_collector</literal>. Note that in both
+  cases, query texts and advice strings are stored in memory, so
+  configuring large limits may result in considerable memory consumption.
+ </para>
+
+ <para>
+  Once the collector is enabled, you can run any queries for which you wish
+  to see the generated plan advice. Then, you can examine what has been
+  collected using whichever of
+  <literal>SELECT * FROM pg_get_collected_local_advice()</literal> or
+  <literal>SELECT * FROM pg_get_collected_shared_advice()</literal>
+  corresponds to the collector you enabled. To discard the collected advice
+  and release memory, you can call
+  <literal>pg_clear_collected_local_advice()</literal>
+  or <literal>pg_clear_collected_shared_advice()</literal>.
+ </para>
+
+ <para>
+  In addition to the query texts and advice strings, the advice collectors
+  will also store the OID of the role that caused the query to be planned,
+  the OID of the database in which the query was planned, the query ID,
+  and the time at which the collection occurred. This module does not
+  automatically enable query ID computation; therefore, if you want the
+  query ID value to be populated in collected advice, be sure to configure
+  <literal>compute_query_id = on</literal>. Otherwise, the query ID may
+  always show as <literal>0</literal>.
+ </para>
+
+ <sect2 id="pgcollectadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_local_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from backend-local
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_local_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the local
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_shared_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from shared
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_shared_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the shared
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collector</varname> enables the
+      local advice collector. The default value is <literal>false</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      local advice collector. The default value is <literal>0</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collector</varname> enables the
+      shared advice collector. The default value is <literal>false</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      shared advice collector. The default value is <literal>0</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index e4ba0146ffb..b260f979e64 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3984,6 +3984,12 @@ pg_uuid_t
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgca_collected_advice
+pgca_local_advice
+pgca_local_advice_chunk
+pgca_shared_advice
+pgca_shared_advice_chunk
+pgca_shared_state
 pgpa_advice_item
 pgpa_advice_tag_type
 pgpa_advice_target
-- 
2.51.0



  [application/octet-stream] v19-0001-Add-pg_plan_advice-contrib-module.patch (448.1K, 5-v19-0001-Add-pg_plan_advice-contrib-module.patch)
  download | inline diff:
From 12b216f7c977697a7e2be6047c5576df6756b297 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 26 Jan 2026 09:56:36 -0500
Subject: [PATCH v19 1/4] Add pg_plan_advice contrib module.

Provide a facility that (1) can be used to stabilize certain plan choices
so that the planner cannot reverse course without authorization and
(2) can be used by knowledgeable users to insist on plan choices contrary
to what the planner believes best. In both cases, terrible outcomes are
possible: users should think twice and perhaps three times before
constraining the planner's ability to do as it thinks best; nevertheless,
there are problems that are much more easily solved with these facilities
than without them.

This patch takes the approach of analyzing a finished plan to produce
textual output, which we call "plan advice", that describes key
decisions made during plan; if that plan advice is provided during
future planning cycles, it will force those key decisions to be made in
the same way.  Not all planner decisions can be controlled using advice;
for example, decisions about how to perform aggregation are currently
out of scope, as is choice of sort order. Plan advice can also be edited
by the user, or even written from scratch in simple cases, making it
possible to generate outcomes that the planner would not have produced.
Partial advice can be provided to control some planner outcomes but not
others.

Currently, plan advice is focused only on specific outcomes, such as
the choice to use a sequential scan for a particular relation, and not
on estimates that might contribute to those outcomes, such as a
possibly-incorrect selectivity estimate. While it would be useful to
users to be able to provide plan advice that affects selectivity
estimates or other aspects of costing, that is out of scope for this
commit.

Reviewed-by: Lukas Fittl <[email protected]>
Reviewed-by: Jakub Wartak <[email protected]>
Reviewed-by: Greg Burd <[email protected]>
Reviewed-by: Jacob Champion <[email protected]>
Reviewed-by: Haibo Yan <[email protected]>
Reviewed-by: Dian Fay <[email protected]>
Reviewed-by: Ajay Pal <[email protected]>
Reviewed-by: John Naylor <[email protected]>
Reviewed-by: Alexandra Wang <[email protected]>
Discussion: http://postgr.es/m/CA+TgmoZ-Jh1T6QyWoCODMVQdhTUPYkaZjWztzP1En4=ZHoKPzw@mail.gmail.com
---
 contrib/Makefile                              |    1 +
 contrib/meson.build                           |    1 +
 contrib/pg_plan_advice/.gitignore             |    3 +
 contrib/pg_plan_advice/Makefile               |   43 +
 contrib/pg_plan_advice/README                 |  260 ++
 contrib/pg_plan_advice/expected/gather.out    |  371 +++
 .../pg_plan_advice/expected/join_order.out    |  500 ++++
 .../pg_plan_advice/expected/join_strategy.out |  339 +++
 .../pg_plan_advice/expected/partitionwise.out |  426 ++++
 contrib/pg_plan_advice/expected/prepared.out  |   67 +
 contrib/pg_plan_advice/expected/scan.out      |  757 ++++++
 contrib/pg_plan_advice/expected/semijoin.out  |  377 +++
 contrib/pg_plan_advice/expected/syntax.out    |  192 ++
 contrib/pg_plan_advice/meson.build            |   66 +
 contrib/pg_plan_advice/pg_plan_advice.c       |  456 ++++
 contrib/pg_plan_advice/pg_plan_advice.h       |   45 +
 contrib/pg_plan_advice/pgpa_ast.c             |  351 +++
 contrib/pg_plan_advice/pgpa_ast.h             |  185 ++
 contrib/pg_plan_advice/pgpa_identifier.c      |  481 ++++
 contrib/pg_plan_advice/pgpa_identifier.h      |   52 +
 contrib/pg_plan_advice/pgpa_join.c            |  638 +++++
 contrib/pg_plan_advice/pgpa_join.h            |  105 +
 contrib/pg_plan_advice/pgpa_output.c          |  571 +++++
 contrib/pg_plan_advice/pgpa_output.h          |   22 +
 contrib/pg_plan_advice/pgpa_parser.y          |  301 +++
 contrib/pg_plan_advice/pgpa_planner.c         | 2198 +++++++++++++++++
 contrib/pg_plan_advice/pgpa_planner.h         |   19 +
 contrib/pg_plan_advice/pgpa_scan.c            |  271 ++
 contrib/pg_plan_advice/pgpa_scan.h            |   85 +
 contrib/pg_plan_advice/pgpa_scanner.l         |  297 +++
 contrib/pg_plan_advice/pgpa_trove.c           |  516 ++++
 contrib/pg_plan_advice/pgpa_trove.h           |  114 +
 contrib/pg_plan_advice/pgpa_walker.c          | 1029 ++++++++
 contrib/pg_plan_advice/pgpa_walker.h          |  141 ++
 contrib/pg_plan_advice/sql/gather.sql         |   86 +
 contrib/pg_plan_advice/sql/join_order.sql     |  145 ++
 contrib/pg_plan_advice/sql/join_strategy.sql  |   84 +
 contrib/pg_plan_advice/sql/partitionwise.sql  |   99 +
 contrib/pg_plan_advice/sql/prepared.sql       |   37 +
 contrib/pg_plan_advice/sql/scan.sql           |  195 ++
 contrib/pg_plan_advice/sql/semijoin.sql       |  118 +
 contrib/pg_plan_advice/sql/syntax.sql         |   68 +
 doc/src/sgml/contrib.sgml                     |    1 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/pgplanadvice.sgml                |  813 ++++++
 src/tools/pgindent/typedefs.list              |   33 +
 46 files changed, 12960 insertions(+)
 create mode 100644 contrib/pg_plan_advice/.gitignore
 create mode 100644 contrib/pg_plan_advice/Makefile
 create mode 100644 contrib/pg_plan_advice/README
 create mode 100644 contrib/pg_plan_advice/expected/gather.out
 create mode 100644 contrib/pg_plan_advice/expected/join_order.out
 create mode 100644 contrib/pg_plan_advice/expected/join_strategy.out
 create mode 100644 contrib/pg_plan_advice/expected/partitionwise.out
 create mode 100644 contrib/pg_plan_advice/expected/prepared.out
 create mode 100644 contrib/pg_plan_advice/expected/scan.out
 create mode 100644 contrib/pg_plan_advice/expected/semijoin.out
 create mode 100644 contrib/pg_plan_advice/expected/syntax.out
 create mode 100644 contrib/pg_plan_advice/meson.build
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.c
 create mode 100644 contrib/pg_plan_advice/pg_plan_advice.h
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.c
 create mode 100644 contrib/pg_plan_advice/pgpa_ast.h
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.c
 create mode 100644 contrib/pg_plan_advice/pgpa_identifier.h
 create mode 100644 contrib/pg_plan_advice/pgpa_join.c
 create mode 100644 contrib/pg_plan_advice/pgpa_join.h
 create mode 100644 contrib/pg_plan_advice/pgpa_output.c
 create mode 100644 contrib/pg_plan_advice/pgpa_output.h
 create mode 100644 contrib/pg_plan_advice/pgpa_parser.y
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.c
 create mode 100644 contrib/pg_plan_advice/pgpa_planner.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.c
 create mode 100644 contrib/pg_plan_advice/pgpa_scan.h
 create mode 100644 contrib/pg_plan_advice/pgpa_scanner.l
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.c
 create mode 100644 contrib/pg_plan_advice/pgpa_trove.h
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.c
 create mode 100644 contrib/pg_plan_advice/pgpa_walker.h
 create mode 100644 contrib/pg_plan_advice/sql/gather.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_order.sql
 create mode 100644 contrib/pg_plan_advice/sql/join_strategy.sql
 create mode 100644 contrib/pg_plan_advice/sql/partitionwise.sql
 create mode 100644 contrib/pg_plan_advice/sql/prepared.sql
 create mode 100644 contrib/pg_plan_advice/sql/scan.sql
 create mode 100644 contrib/pg_plan_advice/sql/semijoin.sql
 create mode 100644 contrib/pg_plan_advice/sql/syntax.sql
 create mode 100644 doc/src/sgml/pgplanadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 2f0a88d3f77..dd04c20acd2 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -34,6 +34,7 @@ SUBDIRS = \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
+		pg_plan_advice \
 		pg_prewarm	\
 		pg_stat_statements \
 		pg_surgery	\
diff --git a/contrib/meson.build b/contrib/meson.build
index def13257cbe..5a752eac347 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -48,6 +48,7 @@ subdir('pgcrypto')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
+subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
 subdir('pg_stat_statements')
diff --git a/contrib/pg_plan_advice/.gitignore b/contrib/pg_plan_advice/.gitignore
new file mode 100644
index 00000000000..19a14253019
--- /dev/null
+++ b/contrib/pg_plan_advice/.gitignore
@@ -0,0 +1,3 @@
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
new file mode 100644
index 00000000000..d2a8233f387
--- /dev/null
+++ b/contrib/pg_plan_advice/Makefile
@@ -0,0 +1,43 @@
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_plan_advice.o \
+	pgpa_ast.o \
+	pgpa_identifier.o \
+	pgpa_join.o \
+	pgpa_output.o \
+	pgpa_parser.o \
+	pgpa_planner.o \
+	pgpa_scan.o \
+	pgpa_scanner.o \
+	pgpa_trove.o \
+	pgpa_walker.o
+
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise prepared \
+	scan semijoin syntax
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+	touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
new file mode 100644
index 00000000000..b0e4fd1d6e1
--- /dev/null
+++ b/contrib/pg_plan_advice/README
@@ -0,0 +1,260 @@
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planning
+cycle, each piece of advice should be guaranteed to apply to exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should successfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it appears to be good advice or bad advice. If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that the same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+   e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously. Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables. When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by parentheses, e.g., SOME_SORT_OF_SCAN(foo (bar baz)) would mean that
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+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.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ORDER() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructed. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Method Advice
+==================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+method advice; no join method advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join method advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the planner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently, and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join method advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join method advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may be made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join method advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advice can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constrain the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the inner
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constrain won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since the primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
+
+XXX Need to investigate whether and how well supplying advice works with GEQO
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
new file mode 100644
index 00000000000..0cc0dedf859
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -0,0 +1,371 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Sort
+   Sort Key: f.dim_id
+   ->  Gather
+         Workers Planned: 1
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((d d/d.d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER((d d/d.d)) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(17 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: f.dim_id
+               ->  Parallel Seq Scan on gt_fact f
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER_MERGE(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Gather Merge
+         Workers Planned: 1
+         ->  Sort
+               Sort Key: d.id
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE(d)
+   NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   GATHER(f)
+   NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+   ->  Sort
+         Sort Key: d.id
+         ->  Gather
+               Workers Planned: 1
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER(d) /* matched */
+   NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   SEQ_SCAN(f d)
+   GATHER(d)
+   NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using gt_dim_pkey on gt_dim d
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Seq Scan on gt_fact f
+ Supplied Plan Advice:
+   NO_GATHER(f) /* matched */
+   NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.gt_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Disabled: true
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Gather
+   Workers Planned: 1
+   ->  Parallel Hash Join
+         Hash Cond: (f.dim_id = d.id)
+         ->  Parallel Seq Scan on gt_fact f
+         ->  Parallel Hash
+               ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER((f d))
+(14 rows)
+
+COMMIT;
+-- Test conflicting advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather((f d)) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Gather Merge
+   Workers Planned: 1
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
+               ->  Parallel Seq Scan on gt_fact f
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+   GATHER((f d)) /* matched, conflicting, failed */
+   NO_GATHER(f) /* matched, conflicting, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   GATHER_MERGE((f d))
+(17 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
new file mode 100644
index 00000000000..a5a9728e3fd
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -0,0 +1,500 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 53) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   HASH_JOIN(d1 d2)
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on jo_fact f
+         ->  Hash
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   HASH_JOIN(d2 d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+               QUERY PLAN                
+-----------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (d1.id = f.dim1_id)
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Hash
+               ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 f d2)
+   HASH_JOIN(f d2)
+   SEQ_SCAN(d1 f d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN((d1 d2))
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f {d1 d2})';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f {d1 d2}) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN((d1 d2))
+   SEQ_SCAN(f d1 d2)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- Force a join order by mentioning just a prefix of the join list.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Join
+   Hash Cond: (d2.id = f.dim2_id)
+   ->  Seq Scan on jo_dim2 d2
+         Filter: (val2 = 1)
+   ->  Hash
+         ->  Hash Join
+               Hash Cond: (f.dim1_id = d1.id)
+               ->  Seq Scan on jo_fact f
+               ->  Hash
+                     ->  Seq Scan on jo_dim1 d1
+                           Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(d2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 (f d1))
+   HASH_JOIN(d1 (f d1))
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((d1.id = f.dim1_id) AND (d2.id = f.dim2_id))
+   ->  Nested Loop
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+         ->  Materialize
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_fact f
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 d1 f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   HASH_JOIN(f)
+   SEQ_SCAN(d2 d1 f)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- jo_fact is not partitioned, but let's try pretending that it is and
+-- verifying that the advice does not apply.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f/d1 d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Nested Loop
+         Disabled: true
+         ->  Seq Scan on jo_fact f
+         ->  Index Scan using jo_dim1_pkey on jo_dim1 d1
+               Index Cond: (id = f.dim1_id)
+               Filter: (val1 = 1)
+   ->  Index Scan using jo_dim2_pkey on jo_dim2 d2
+         Index Cond: (id = f.dim2_id)
+         Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f/d1 d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d1 d2)
+   NESTED_LOOP_PLAIN(d1 d2)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d1 public.jo_dim1_pkey d2 public.jo_dim2_pkey)
+   NO_GATHER(f d1 d2)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f/d1 (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) AND (d2.id = f.dim2_id))
+   ->  Nested Loop
+         ->  Seq Scan on jo_dim1 d1
+               Filter: (val1 = 1)
+         ->  Materialize
+               ->  Seq Scan on jo_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Seq Scan on jo_fact f
+ Supplied Plan Advice:
+   JOIN_ORDER(f/d1 (d1 d2)) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 d2 f)
+   NESTED_LOOP_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d2)
+   SEQ_SCAN(d1 d2 f)
+   NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Disabled: true
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_PLAIN(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d2 f d1)
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(d2 f d1)
+   NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- Two incompatible join orders should conflict. In the second case,
+-- the conflict is implicit: if d1 is on the inner side of a join of any
+-- type, it cannot also be the driving table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f) join_order(d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Merge Full Join
+         Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+         ->  Sort
+               Sort Key: ((f.dim2_id + 0))
+               ->  Seq Scan on jo_fact f
+         ->  Sort
+               Sort Key: ((d2.id + 0))
+               ->  Seq Scan on jo_dim2 d2
+   ->  Materialize
+         ->  Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+   JOIN_ORDER(f) /* matched, conflicting */
+   JOIN_ORDER(d1) /* matched, conflicting, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d2 d1)
+   MERGE_JOIN_PLAIN(d2)
+   NESTED_LOOP_MATERIALIZE(d1)
+   SEQ_SCAN(f d2 d1)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1) hash_join(d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Nested Loop
+   Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+   ->  Seq Scan on jo_dim1 d1
+   ->  Materialize
+         ->  Merge Full Join
+               Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+               ->  Sort
+                     Sort Key: ((d2.id + 0))
+                     ->  Seq Scan on jo_dim2 d2
+               ->  Sort
+                     Sort Key: ((f.dim2_id + 0))
+                     ->  Seq Scan on jo_fact f
+ Supplied Plan Advice:
+   JOIN_ORDER(d1) /* matched, conflicting */
+   HASH_JOIN(d1) /* matched, conflicting, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(d1 (d2 f))
+   MERGE_JOIN_PLAIN(f)
+   NESTED_LOOP_MATERIALIZE((f d2))
+   SEQ_SCAN(d1 d2 f)
+   NO_GATHER(d1 f d2)
+(21 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/join_strategy.out b/contrib/pg_plan_advice/expected/join_strategy.out
new file mode 100644
index 00000000000..0f9db692190
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/join_strategy.out
@@ -0,0 +1,339 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Disabled: true
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (f.dim_id = d.id)
+   ->  Index Scan using join_fact_dim_id on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   MERGE_JOIN_PLAIN(d)
+   INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Materialize
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MATERIALIZE(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Memoize
+         Cache Key: f.dim_id
+         Cache Mode: logical
+         ->  Index Scan using join_dim_pkey on join_dim d
+               Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_MEMOIZE(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN              
+-------------------------------------
+ Hash Join
+   Hash Cond: (d.id = f.dim_id)
+   ->  Seq Scan on join_dim d
+   ->  Hash
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   HASH_JOIN(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Materialize
+         ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_MATERIALIZE(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Join Filter: (f.dim_id = d.id)
+   ->  Seq Scan on join_dim d
+   ->  Materialize
+         ->  Seq Scan on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MATERIALIZE(f)
+   SEQ_SCAN(d f)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Memoize
+         Cache Key: d.id
+         Cache Mode: logical
+         ->  Index Scan using join_fact_dim_id on join_fact f
+               Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_MEMOIZE(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+         Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   NESTED_LOOP_PLAIN(f)
+   SEQ_SCAN(d)
+   INDEX_SCAN(f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Non-working cases. We can't force a foreign join between these tables,
+-- because they aren't foreign tables. We also can't use two different
+-- strategies on the same table, nor can we put both tables on the inner
+-- side of the same join.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(13 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f) NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Merge Join
+   Merge Cond: (d.id = f.dim_id)
+   ->  Index Scan using join_dim_pkey on join_dim d
+   ->  Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched, conflicting, failed */
+   NESTED_LOOP_MATERIALIZE(f) /* matched, conflicting, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(d f)
+   MERGE_JOIN_PLAIN(f)
+   INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+   NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(f) /* matched, failed */
+   NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   NESTED_LOOP_PLAIN(d)
+   SEQ_SCAN(f)
+   INDEX_SCAN(d public.join_dim_pkey)
+   NO_GATHER(f d)
+(14 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/partitionwise.out b/contrib/pg_plan_advice/expected/partitionwise.out
new file mode 100644
index 00000000000..2b3d0a82443
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/partitionwise.out
@@ -0,0 +1,426 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000,2) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000,3) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (pt2.id = pt3.id)
+         ->  Append
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+         ->  Hash
+               ->  Append
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+   ->  Append
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2.id)
+               Filter: (val1 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2.id)
+               Filter: (val1 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2.id)
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE(pt1) /* matched */
+   PARTITIONWISE(pt2) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt2 pt3 pt1)
+   NESTED_LOOP_PLAIN(pt1)
+   HASH_JOIN(pt3)
+   SEQ_SCAN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a
+    pt3/public.pt3b pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE(pt2 pt3 pt1)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(43 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (pt1.id = pt3.id)
+   ->  Append
+         ->  Hash Join
+               Hash Cond: (pt1_1.id = pt2_1.id)
+               ->  Seq Scan on pt1a pt1_1
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_2.id = pt2_2.id)
+               ->  Seq Scan on pt1b pt1_2
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2b pt2_2
+                           Filter: (val2 = 1)
+         ->  Hash Join
+               Hash Cond: (pt1_3.id = pt2_3.id)
+               ->  Seq Scan on pt1c pt1_3
+                     Filter: (val1 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2c pt2_3
+                           Filter: (val2 = 1)
+   ->  Hash
+         ->  Append
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3b pt3_2
+                     Filter: (val3 = 1)
+               ->  Seq Scan on pt3c pt3_3
+                     Filter: (val3 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched */
+   PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+   JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+   JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+   JOIN_ORDER({pt1 pt2} pt3)
+   HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+   SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+    pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+    pt3/public.pt3c)
+   PARTITIONWISE((pt1 pt2) pt3)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Test conflicting advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) (pt1 pt3))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   Disabled: true
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_1.id = pt3_1.id)
+               ->  Seq Scan on pt2a pt2_1
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3a pt3_1
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 pt2)) /* matched, conflicting, failed */
+   PARTITIONWISE((pt1 pt3)) /* matched, conflicting, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(51 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Append
+         ->  Seq Scan on pt1a pt1_1
+         ->  Seq Scan on pt1b pt1_2
+         ->  Seq Scan on pt1c pt1_3
+   ->  Append
+         ->  Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+               Index Cond: (id = pt1.id)
+         ->  Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+               Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+   PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(pt1 ptmismatch)
+   NESTED_LOOP_PLAIN(ptmismatch)
+   SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+    ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+   PARTITIONWISE(pt1 ptmismatch)
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+    ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
+-- Force join order for a particular branch of the partitionwise join with
+-- and without mentioning the schema name.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'JOIN_ORDER(pt3/public.pt3a pt2/public.pt2a pt1/public.pt1a)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt3_1.id = pt2_1.id)
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(pt3/public.pt3a pt2/public.pt2a pt1/public.pt1a) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt3/public.pt3a pt2/public.pt2a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt2/public.pt2a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt3/public.pt3a pt2/public.pt2a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(49 rows)
+
+SET LOCAL pg_plan_advice.advice = 'JOIN_ORDER(pt3/pt3a pt2/pt2a pt1/pt1a)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Append
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt3_1.id = pt2_1.id)
+               ->  Seq Scan on pt3a pt3_1
+                     Filter: (val3 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt2a pt2_1
+                           Filter: (val2 = 1)
+         ->  Index Scan using pt1a_pkey on pt1a pt1_1
+               Index Cond: (id = pt2_1.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_2.id = pt3_2.id)
+               ->  Seq Scan on pt2b pt2_2
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3b pt3_2
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1b_pkey on pt1b pt1_2
+               Index Cond: (id = pt2_2.id)
+               Filter: (val1 = 1)
+   ->  Nested Loop
+         ->  Hash Join
+               Hash Cond: (pt2_3.id = pt3_3.id)
+               ->  Seq Scan on pt2c pt2_3
+                     Filter: (val2 = 1)
+               ->  Hash
+                     ->  Seq Scan on pt3c pt3_3
+                           Filter: (val3 = 1)
+         ->  Index Scan using pt1c_pkey on pt1c pt1_3
+               Index Cond: (id = pt2_3.id)
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(pt3/pt3a pt2/pt2a pt1/pt1a) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(pt3/public.pt3a pt2/public.pt2a pt1/public.pt1a)
+   JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+   JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+   NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+   HASH_JOIN(pt2/public.pt2a pt3/public.pt3b pt3/public.pt3c)
+   SEQ_SCAN(pt3/public.pt3a pt2/public.pt2a pt2/public.pt2b pt3/public.pt3b
+    pt2/public.pt2c pt3/public.pt3c)
+   INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+    pt1/public.pt1c public.pt1c_pkey)
+   PARTITIONWISE((pt1 pt2 pt3))
+   NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+    pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(49 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/prepared.out b/contrib/pg_plan_advice/expected/prepared.out
new file mode 100644
index 00000000000..07a7c623659
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/prepared.out
@@ -0,0 +1,67 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE ptab (id integer, val text) WITH (autovacuum_enabled = false);
+SET pg_plan_advice.always_store_advice_details = false;
+-- Not prepared, so advice should be generated.
+EXPLAIN (COSTS OFF, PLAN_ADVICE) 
+SELECT * FROM ptab;
+       QUERY PLAN       
+------------------------
+ Seq Scan on ptab
+ Generated Plan Advice:
+   SEQ_SCAN(ptab)
+   NO_GATHER(ptab)
+(4 rows)
+
+-- Prepared, so advice should not be generated.
+PREPARE pt1 AS SELECT * FROM ptab;
+EXPLAIN (COSTS OFF, PLAN_ADVICE) EXECUTE pt1;
+    QUERY PLAN    
+------------------
+ Seq Scan on ptab
+(1 row)
+
+SET pg_plan_advice.always_store_advice_details = true;
+-- Prepared, but always_store_advice_details = true, so should show advice.
+PREPARE pt2 AS SELECT * FROM ptab;
+EXPLAIN (COSTS OFF, PLAN_ADVICE) EXECUTE pt2;
+       QUERY PLAN       
+------------------------
+ Seq Scan on ptab
+ Generated Plan Advice:
+   SEQ_SCAN(ptab)
+   NO_GATHER(ptab)
+(4 rows)
+
+-- Not prepared, so feedback should be generated.
+SET pg_plan_advice.always_store_advice_details = false;
+SET pg_plan_advice.advice = 'SEQ_SCAN(ptab)';
+EXPLAIN (COSTS OFF) 
+SELECT * FROM ptab;
+           QUERY PLAN           
+--------------------------------
+ Seq Scan on ptab
+ Supplied Plan Advice:
+   SEQ_SCAN(ptab) /* matched */
+(3 rows)
+
+-- Prepared, so advice should not be generated.
+PREPARE pt3 AS SELECT * FROM ptab;
+EXPLAIN (COSTS OFF) EXECUTE pt1;
+    QUERY PLAN    
+------------------
+ Seq Scan on ptab
+(1 row)
+
+SET pg_plan_advice.always_store_advice_details = true;
+-- Prepared, but always_store_advice_details = true, so should show feedback.
+PREPARE pt4 AS SELECT * FROM ptab;
+EXPLAIN (COSTS OFF, PLAN_ADVICE) EXECUTE pt2;
+       QUERY PLAN       
+------------------------
+ Seq Scan on ptab
+ Generated Plan Advice:
+   SEQ_SCAN(ptab)
+   NO_GATHER(ptab)
+(4 rows)
+
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
new file mode 100644
index 00000000000..3f9e13b6d41
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -0,0 +1,757 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+       QUERY PLAN        
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                  QUERY PLAN                   
+-----------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+            QUERY PLAN             
+-----------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+                  QUERY PLAN                   
+-----------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (b > 'some text 8'::text)
+   ->  Bitmap Index Scan on scan_table_b
+         Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Tid Scan on scan_table
+   TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+   TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   TID_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+                  QUERY PLAN                  
+----------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+ Supplied Plan Advice:
+   TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a > 0)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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 > 0;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+   Disabled: true
+   Filter: (a > 0)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+                  QUERY PLAN                  
+----------------------------------------------
+ Bitmap Heap Scan on scan_table
+   Recheck Cond: (a > 0)
+   ->  Bitmap Index Scan on scan_table_pkey
+         Index Cond: (a > 0)
+ Supplied Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   BITMAP_HEAP_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(9 rows)
+
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+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;
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Disabled: true
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_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:
+   TID_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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+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:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+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:
+   INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+              QUERY PLAN              
+--------------------------------------
+ Seq Scan on scan_table
+   Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Seq Scan on scan_table
+   Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+   SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(scan_table)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table cilbup.scan_table_pkey) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table scan_table_pkey) /* matched, conflicting */
+   INDEX_SCAN(scan_table public.scan_table_pkey) /* matched, conflicting */
+ Generated Plan Advice:
+   INDEX_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                      QUERY PLAN                      
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+   NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Nested Loop Left Join
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s s#2)
+   INDEX_SCAN(s public.scan_table_pkey s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Nested Loop Left Join
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Index Scan using scan_table_pkey on scan_table s_1
+         Index Cond: (a = g.g)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s#2)
+   HASH_JOIN(s)
+   SEQ_SCAN(s)
+   INDEX_SCAN(s#2 public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Nested Loop Left Join
+         ->  Function Scan on generate_series g
+         ->  Index Scan using scan_table_pkey on scan_table s
+               Index Cond: (a = g.g)
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   NESTED_LOOP_PLAIN(s)
+   HASH_JOIN(s#2)
+   SEQ_SCAN(s#2)
+   INDEX_SCAN(s public.scan_table_pkey)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Left Join
+   Hash Cond: (g.g = s_1.a)
+   ->  Hash Left Join
+         Hash Cond: (g.g = s.a)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on scan_table s
+   ->  Hash
+         ->  Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* matched */
+   SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g s s#2)
+   HASH_JOIN(s s#2)
+   SEQ_SCAN(s s#2)
+   NO_GATHER(g s s#2)
+(17 rows)
+
+COMMIT;
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(5 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(5 rows)
+
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+          QUERY PLAN           
+-------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@x)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@x) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+   Index Cond: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* not matched */
+ Generated Plan Advice:
+   INDEX_SCAN(s@x public.scan_table_pkey)
+   NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+                    QUERY PLAN                    
+--------------------------------------------------
+ Seq Scan on scan_table s
+   Filter: (a = 1)
+ Supplied Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery) /* matched */
+ Generated Plan Advice:
+   SEQ_SCAN(s@unnamed_subquery)
+   NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/semijoin.out b/contrib/pg_plan_advice/expected/semijoin.out
new file mode 100644
index 00000000000..5551c028a1f
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/semijoin.out
@@ -0,0 +1,377 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE sj_wide (
+	id integer primary key,
+	val1 integer,
+	padding text storage plain
+) WITH (autovacuum_enabled = false);
+INSERT INTO sj_wide
+	SELECT g, g%10+1, repeat(' ', 300) FROM generate_series(1, 1000) g;
+CREATE INDEX ON sj_wide (val1);
+VACUUM ANALYZE sj_wide;
+CREATE TABLE sj_narrow (
+	id integer primary key,
+	val1 integer
+) WITH (autovacuum_enabled = false);
+INSERT INTO sj_narrow
+	SELECT g, g%10+1 FROM generate_series(1, 1000) g;
+CREATE INDEX ON sj_narrow (val1);
+VACUUM ANALYZE sj_narrow;
+-- We expect this to make the VALUES list unique and use index lookups to
+-- find the rows in sj_wide, so as to avoid a full scan of sj_wide.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_wide
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Nested Loop
+   ->  HashAggregate
+         Group Key: "*VALUES*".column1, "*VALUES*".column2
+         ->  Values Scan on "*VALUES*"
+   ->  Index Scan using sj_wide_pkey on sj_wide
+         Index Cond: (id = "*VALUES*".column1)
+         Filter: (val1 = "*VALUES*".column2)
+ Generated Plan Advice:
+   JOIN_ORDER("*VALUES*" sj_wide)
+   NESTED_LOOP_PLAIN(sj_wide)
+   INDEX_SCAN(sj_wide public.sj_wide_pkey)
+   SEMIJOIN_UNIQUE("*VALUES*")
+   NO_GATHER(sj_wide "*VALUES*")
+(13 rows)
+
+-- If we ask for a unique semijoin, we should get the same plan as with
+-- no advice. If we ask for a non-unique semijoin, we should see a Semi
+-- Join operation in the plan tree.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique("*VALUES*")';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_wide
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Nested Loop
+   ->  HashAggregate
+         Group Key: "*VALUES*".column1, "*VALUES*".column2
+         ->  Values Scan on "*VALUES*"
+   ->  Index Scan using sj_wide_pkey on sj_wide
+         Index Cond: (id = "*VALUES*".column1)
+         Filter: (val1 = "*VALUES*".column2)
+ Supplied Plan Advice:
+   SEMIJOIN_UNIQUE("*VALUES*") /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER("*VALUES*" sj_wide)
+   NESTED_LOOP_PLAIN(sj_wide)
+   INDEX_SCAN(sj_wide public.sj_wide_pkey)
+   SEMIJOIN_UNIQUE("*VALUES*")
+   NO_GATHER(sj_wide "*VALUES*")
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique("*VALUES*")';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_wide
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+                                        QUERY PLAN                                        
+------------------------------------------------------------------------------------------
+ Hash Semi Join
+   Hash Cond: ((sj_wide.id = "*VALUES*".column1) AND (sj_wide.val1 = "*VALUES*".column2))
+   ->  Seq Scan on sj_wide
+   ->  Hash
+         ->  Values Scan on "*VALUES*"
+ Supplied Plan Advice:
+   SEMIJOIN_NON_UNIQUE("*VALUES*") /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(sj_wide "*VALUES*")
+   HASH_JOIN("*VALUES*")
+   SEQ_SCAN(sj_wide)
+   SEMIJOIN_NON_UNIQUE("*VALUES*")
+   NO_GATHER(sj_wide "*VALUES*")
+(13 rows)
+
+COMMIT;
+-- Because this table is narrower than the previous one, a sequential scan
+-- is less expensive, and we choose a straightforward Semi Join plan by
+-- default. (Note that this is also very sensitive to the length of the IN
+-- list, which affects how many index lookups the alternative plan will need.)
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_narrow
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+                                          QUERY PLAN                                          
+----------------------------------------------------------------------------------------------
+ Hash Semi Join
+   Hash Cond: ((sj_narrow.id = "*VALUES*".column1) AND (sj_narrow.val1 = "*VALUES*".column2))
+   ->  Seq Scan on sj_narrow
+   ->  Hash
+         ->  Values Scan on "*VALUES*"
+ Generated Plan Advice:
+   JOIN_ORDER(sj_narrow "*VALUES*")
+   HASH_JOIN("*VALUES*")
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_NON_UNIQUE("*VALUES*")
+   NO_GATHER(sj_narrow "*VALUES*")
+(11 rows)
+
+-- Here, we expect advising a unique semijoin to swith to the same plan that
+-- we got with sj_wide, and advising a non-unique semijoin should not change
+-- the plan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique("*VALUES*")';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_narrow
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+                                          QUERY PLAN                                          
+----------------------------------------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((sj_narrow.id = "*VALUES*".column1) AND (sj_narrow.val1 = "*VALUES*".column2))
+   ->  Seq Scan on sj_narrow
+   ->  Hash
+         ->  HashAggregate
+               Group Key: "*VALUES*".column1, "*VALUES*".column2
+               ->  Values Scan on "*VALUES*"
+ Supplied Plan Advice:
+   SEMIJOIN_UNIQUE("*VALUES*") /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(sj_narrow "*VALUES*")
+   HASH_JOIN("*VALUES*")
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_UNIQUE("*VALUES*")
+   NO_GATHER(sj_narrow "*VALUES*")
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique("*VALUES*")';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_narrow
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+                                          QUERY PLAN                                          
+----------------------------------------------------------------------------------------------
+ Hash Semi Join
+   Hash Cond: ((sj_narrow.id = "*VALUES*".column1) AND (sj_narrow.val1 = "*VALUES*".column2))
+   ->  Seq Scan on sj_narrow
+   ->  Hash
+         ->  Values Scan on "*VALUES*"
+ Supplied Plan Advice:
+   SEMIJOIN_NON_UNIQUE("*VALUES*") /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(sj_narrow "*VALUES*")
+   HASH_JOIN("*VALUES*")
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_NON_UNIQUE("*VALUES*")
+   NO_GATHER(sj_narrow "*VALUES*")
+(13 rows)
+
+COMMIT;
+-- In the above example, we made the outer side of the join unique, but here,
+-- we should make the inner side unique.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (g.g = sj_narrow.val1)
+   ->  Function Scan on generate_series g
+   ->  Hash
+         ->  HashAggregate
+               Group Key: sj_narrow.val1
+               ->  Seq Scan on sj_narrow
+ Generated Plan Advice:
+   JOIN_ORDER(g sj_narrow)
+   HASH_JOIN(sj_narrow)
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_UNIQUE(sj_narrow)
+   NO_GATHER(g sj_narrow)
+(13 rows)
+
+-- We should be able to force a plan with or without the make-unique strategy,
+-- with either side as the driving table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+                 QUERY PLAN                 
+--------------------------------------------
+ Hash Join
+   Hash Cond: (g.g = sj_narrow.val1)
+   ->  Function Scan on generate_series g
+   ->  Hash
+         ->  HashAggregate
+               Group Key: sj_narrow.val1
+               ->  Seq Scan on sj_narrow
+ Supplied Plan Advice:
+   SEMIJOIN_UNIQUE(sj_narrow) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g sj_narrow)
+   HASH_JOIN(sj_narrow)
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_UNIQUE(sj_narrow)
+   NO_GATHER(g sj_narrow)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Semi Join
+   Hash Cond: (g.g = sj_narrow.val1)
+   ->  Function Scan on generate_series g
+   ->  Hash
+         ->  Seq Scan on sj_narrow
+ Supplied Plan Advice:
+   SEMIJOIN_NON_UNIQUE(sj_narrow) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(g sj_narrow)
+   HASH_JOIN(sj_narrow)
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_NON_UNIQUE(sj_narrow)
+   NO_GATHER(g sj_narrow)
+(13 rows)
+
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(sj_narrow) join_order(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Join
+   Hash Cond: (sj_narrow.val1 = g.g)
+   ->  HashAggregate
+         Group Key: sj_narrow.val1
+         ->  Seq Scan on sj_narrow
+   ->  Hash
+         ->  Function Scan on generate_series g
+ Supplied Plan Advice:
+   SEMIJOIN_UNIQUE(sj_narrow) /* matched */
+   JOIN_ORDER(sj_narrow) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(sj_narrow g)
+   HASH_JOIN(g)
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_UNIQUE(sj_narrow)
+   NO_GATHER(g sj_narrow)
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique(sj_narrow) join_order(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+                   QUERY PLAN                   
+------------------------------------------------
+ Hash Right Semi Join
+   Hash Cond: (sj_narrow.val1 = g.g)
+   ->  Seq Scan on sj_narrow
+   ->  Hash
+         ->  Function Scan on generate_series g
+ Supplied Plan Advice:
+   SEMIJOIN_NON_UNIQUE(sj_narrow) /* matched */
+   JOIN_ORDER(sj_narrow) /* matched */
+ Generated Plan Advice:
+   JOIN_ORDER(sj_narrow g)
+   HASH_JOIN(g)
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_NON_UNIQUE(sj_narrow)
+   NO_GATHER(g sj_narrow)
+(14 rows)
+
+COMMIT;
+-- However, mentioning the wrong side of the join should result in an advice
+-- failure.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(g)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+                 QUERY PLAN                 
+--------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: (g.g = sj_narrow.val1)
+   ->  HashAggregate
+         Group Key: sj_narrow.val1
+         ->  Seq Scan on sj_narrow
+   ->  Function Scan on generate_series g
+ Supplied Plan Advice:
+   SEMIJOIN_UNIQUE(g) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(sj_narrow g)
+   NESTED_LOOP_PLAIN(g)
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_UNIQUE(sj_narrow)
+   NO_GATHER(g sj_narrow)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique(g)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+                   QUERY PLAN                   
+------------------------------------------------
+ Nested Loop
+   Disabled: true
+   Join Filter: (g.g = sj_narrow.val1)
+   ->  HashAggregate
+         Group Key: sj_narrow.val1
+         ->  Seq Scan on sj_narrow
+   ->  Function Scan on generate_series g
+ Supplied Plan Advice:
+   SEMIJOIN_NON_UNIQUE(g) /* matched, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(sj_narrow g)
+   NESTED_LOOP_PLAIN(g)
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_UNIQUE(sj_narrow)
+   NO_GATHER(g sj_narrow)
+(15 rows)
+
+COMMIT;
+-- Test conflicting advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(sj_narrow) semijoin_non_unique(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Hash Join
+   Hash Cond: (g.g = sj_narrow.val1)
+   ->  Function Scan on generate_series g
+   ->  Hash
+         ->  HashAggregate
+               Group Key: sj_narrow.val1
+               ->  Seq Scan on sj_narrow
+ Supplied Plan Advice:
+   SEMIJOIN_UNIQUE(sj_narrow) /* matched, conflicting */
+   SEMIJOIN_NON_UNIQUE(sj_narrow) /* matched, conflicting, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(g sj_narrow)
+   HASH_JOIN(sj_narrow)
+   SEQ_SCAN(sj_narrow)
+   SEMIJOIN_UNIQUE(sj_narrow)
+   NO_GATHER(g sj_narrow)
+(16 rows)
+
+COMMIT;
+-- Try applying SEMIJOIN_UNIQUE() to a non-semijoin.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(g)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g, sj_narrow s WHERE g = s.val1;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Merge Join
+   Merge Cond: (s.val1 = g.g)
+   ->  Index Scan using sj_narrow_val1_idx on sj_narrow s
+   ->  Sort
+         Sort Key: g.g
+         ->  Function Scan on generate_series g
+ Supplied Plan Advice:
+   SEMIJOIN_UNIQUE(g) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+   JOIN_ORDER(s g)
+   MERGE_JOIN_PLAIN(g)
+   INDEX_SCAN(s public.sj_narrow_val1_idx)
+   NO_GATHER(g s)
+(13 rows)
+
+COMMIT;
diff --git a/contrib/pg_plan_advice/expected/syntax.out b/contrib/pg_plan_advice/expected/syntax.out
new file mode 100644
index 00000000000..be61402b569
--- /dev/null
+++ b/contrib/pg_plan_advice/expected/syntax.out
@@ -0,0 +1,192 @@
+LOAD 'pg_plan_advice';
+-- An empty string is allowed. Empty target lists are allowed for most advice
+-- tags, but not for JOIN_ORDER. "Supplied Plan Advice" should be omitted in
+-- text format when there is no actual advice, but not in non-text format.
+SET pg_plan_advice.advice = '';
+EXPLAIN (COSTS OFF) SELECT 1;
+ QUERY PLAN 
+------------
+ Result
+(1 row)
+
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+EXPLAIN (COSTS OFF) SELECT 1;
+ QUERY PLAN 
+------------
+ Result
+(1 row)
+
+SET pg_plan_advice.advice = 'NESTED_LOOP_PLAIN()';
+EXPLAIN (COSTS OFF, FORMAT JSON) SELECT 1;
+           QUERY PLAN           
+--------------------------------
+ [                             +
+   {                           +
+     "Plan": {                 +
+       "Node Type": "Result",  +
+       "Parallel Aware": false,+
+       "Async Capable": false, +
+       "Disabled": false       +
+     },                        +
+     "Supplied Plan Advice": ""+
+   }                           +
+ ]
+(1 row)
+
+SET pg_plan_advice.advice = 'JOIN_ORDER()';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "JOIN_ORDER()"
+DETAIL:  Could not parse advice: JOIN_ORDER must have at least one target at or near ")"
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+EXPLAIN (COSTS OFF) SELECT 1;
+           QUERY PLAN            
+---------------------------------
+ Result
+ Supplied Plan Advice:
+   SEQ_SCAN(x) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+EXPLAIN (COSTS OFF) SELECT 1;
+            QUERY PLAN             
+-----------------------------------
+ Result
+ Supplied Plan Advice:
+   SEQ_SCAN(x@y) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+EXPLAIN (COSTS OFF) SELECT 1;
+            QUERY PLAN             
+-----------------------------------
+ Result
+ Supplied Plan Advice:
+   SEQ_SCAN(x#2) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+EXPLAIN (COSTS OFF) SELECT 1;
+            QUERY PLAN             
+-----------------------------------
+ Result
+ Supplied Plan Advice:
+   SEQ_SCAN(x/y) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+EXPLAIN (COSTS OFF) SELECT 1;
+             QUERY PLAN              
+-------------------------------------
+ Result
+ Supplied Plan Advice:
+   SEQ_SCAN(x/y.z) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+EXPLAIN (COSTS OFF) SELECT 1;
+               QUERY PLAN                
+-----------------------------------------
+ Result
+ Supplied Plan Advice:
+   SEQ_SCAN(x#2/y.z@t) /* not matched */
+(3 rows)
+
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQUENTIAL_SCAN(x)"
+DETAIL:  Could not parse advice: syntax error at or near "SEQUENTIAL_SCAN"
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN"
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(""
+DETAIL:  Could not parse advice: unterminated quoted identifier at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("")';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("")"
+DETAIL:  Could not parse advice: zero-length delimited identifier at or near """
+SET pg_plan_advice.advice = 'SEQ_SCAN("a"';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("a""
+DETAIL:  Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(#"
+DETAIL:  Could not parse advice: syntax error at or near "#"
+SET pg_plan_advice.advice = '()';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "()"
+DETAIL:  Could not parse advice: syntax error at or near "("
+SET pg_plan_advice.advice = '123';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "123"
+DETAIL:  Could not parse advice: syntax error at or near "123"
+-- Tags like SEQ_SCAN and NO_GATHER don't allow sublists at all; other tags,
+-- except for JOIN_ORDER, allow at most one level of sublist. Hence, these
+-- examples should error out.
+SET pg_plan_advice.advice = 'SEQ_SCAN((x))';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN((x))"
+DETAIL:  Could not parse advice: syntax error at or near "("
+SET pg_plan_advice.advice = 'GATHER(((x)))';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "GATHER(((x)))"
+DETAIL:  Could not parse advice: syntax error at or near "("
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+EXPLAIN (COSTS OFF) SELECT 1;
+ QUERY PLAN 
+------------
+ Result
+(1 row)
+
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+EXPLAIN (COSTS OFF) SELECT 1;
+            QUERY PLAN            
+----------------------------------
+ Result
+ Supplied Plan Advice:
+   HASH_JOIN(_) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+EXPLAIN (COSTS OFF) SELECT 1;
+            QUERY PLAN            
+----------------------------------
+ Result
+ Supplied Plan Advice:
+   HASH_JOIN(y) /* not matched */
+(3 rows)
+
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+EXPLAIN (COSTS OFF) SELECT 1;
+             QUERY PLAN             
+------------------------------------
+ Result
+ Supplied Plan Advice:
+   HASH_JOIN(y/z) /* not matched */
+(3 rows)
+
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "JOIN_ORDER("fOO") /* oops"
+DETAIL:  Could not parse advice: unterminated comment at end of input
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+EXPLAIN (COSTS OFF) SELECT 1;
+ QUERY PLAN 
+------------
+ Result
+(1 row)
+
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "/*/* stuff */*/"
+DETAIL:  Could not parse advice: syntax error at or near "*"
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN(a)"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
+ERROR:  invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN((a))"
+DETAIL:  Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
new file mode 100644
index 00000000000..cf948ffaa13
--- /dev/null
+++ b/contrib/pg_plan_advice/meson.build
@@ -0,0 +1,66 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+  'pg_plan_advice.c',
+  'pgpa_ast.c',
+  'pgpa_identifier.c',
+  'pgpa_join.c',
+  'pgpa_output.c',
+  'pgpa_planner.c',
+  'pgpa_scan.c',
+  'pgpa_trove.c',
+  'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+  input: 'pgpa_scanner.l',
+  output: 'pgpa_scanner.c',
+  command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+  input: 'pgpa_parser.y',
+  kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+  pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_plan_advice',
+    '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice_inc = include_directories('.')
+
+pg_plan_advice = shared_module('pg_plan_advice',
+  pg_plan_advice_sources,
+  include_directories: pg_plan_advice_inc,
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_headers(
+  'pg_plan_advice.h',
+  install_dir: dir_include_extension / 'pg_plan_advice',
+)
+
+tests += {
+  'name': 'pg_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gather',
+      'join_order',
+      'join_strategy',
+      'partitionwise',
+      'prepared',
+      'scan',
+      'semijoin',
+      'syntax',
+    ],
+  },
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.c b/contrib/pg_plan_advice/pg_plan_advice.c
new file mode 100644
index 00000000000..2e0caa28466
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.c
@@ -0,0 +1,456 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ *	  main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+/* GUC variables */
+char	   *pg_plan_advice_advice = NULL;
+bool		pg_plan_advice_always_store_advice_details = false;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+bool		pg_plan_advice_feedback_warnings = false;
+bool		pg_plan_advice_trace_mask = false;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int	es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+static List *advisor_hook_list = NIL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+												  DefElem *opt,
+												  ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+												 IntoClause *into,
+												 ExplainState *es,
+												 const char *queryString,
+												 ParamListInfo params,
+												 QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+											 GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("pg_plan_advice.advice",
+							   "advice to apply during query planning",
+							   NULL,
+							   &pg_plan_advice_advice,
+							   NULL,
+							   PGC_USERSET,
+							   0,
+							   pg_plan_advice_advice_check_hook,
+							   NULL,
+							   NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+							 "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+							 NULL,
+							 &pg_plan_advice_always_explain_supplied_advice,
+							 true,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.always_store_advice_details",
+							 "Generate advice strings even when seemingly not required",
+							 "Use this option to see generated advice for prepared queries.",
+							 &pg_plan_advice_always_store_advice_details,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.feedback_warnings",
+							 "Warn when supplied advice does not apply cleanly",
+							 NULL,
+							 &pg_plan_advice_feedback_warnings,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	DefineCustomBoolVariable("pg_plan_advice.trace_mask",
+							 "Emit debugging messages showing the computed strategy mask for each relation",
+							 NULL,
+							 &pg_plan_advice_trace_mask,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
+	MarkGUCPrefixReserved("pg_plan_advice");
+
+	/* Get an ID that we can use to cache data in an ExplainState. */
+	es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+	/* Register the new EXPLAIN options implemented by this module. */
+	RegisterExtensionExplainOption("plan_advice",
+								   pg_plan_advice_explain_option_handler);
+
+	/* Install hooks */
+	pgpa_planner_install_hooks();
+	prev_explain_per_plan = explain_per_plan_hook;
+	explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+	if (pgpa_memory_context == NULL)
+		pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_plan_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgpa_memory_context;
+}
+
+/*
+ * Was the PLAN_ADVICE option specified and not set to false?
+ */
+bool
+pg_plan_advice_should_explain(ExplainState *es)
+{
+	bool	   *plan_advice = NULL;
+
+	if (es != NULL)
+		plan_advice = GetExplainExtensionState(es, es_extension_id);
+	return plan_advice != NULL && *plan_advice;
+}
+
+/*
+ * Get the advice that should be used while planning a particular query.
+ */
+char *
+pg_plan_advice_get_supplied_query_advice(PlannerGlobal *glob,
+										 Query *parse,
+										 const char *query_string,
+										 int cursorOptions,
+										 ExplainState *es)
+{
+	ListCell   *lc;
+
+	/*
+	 * If any advisors are loaded, consult them. The first one that produces a
+	 * non-NULL string wins.
+	 */
+	foreach(lc, advisor_hook_list)
+	{
+		pg_plan_advice_advisor_hook hook = lfirst(lc);
+		char	   *advice_string;
+
+		advice_string = (*hook) (glob, parse, query_string, cursorOptions, es);
+		if (advice_string != NULL)
+			return advice_string;
+	}
+
+	/* Otherwise, just use the value of the GUC. */
+	return pg_plan_advice_advice;
+}
+
+/*
+ * Add an advisor, which can supply advice strings to be used during future
+ * query planning operations.
+ *
+ * The advisor should return NULL if it has no advice string to offer for a
+ * given query. If multiple advisors are added, they will be consulted in the
+ * order added until one of them returns a non-NULL value.
+ */
+void
+pg_plan_advice_add_advisor(pg_plan_advice_advisor_hook hook)
+{
+	MemoryContext oldcontext;
+
+	oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+	advisor_hook_list = lappend(advisor_hook_list, hook);
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Remove an advisor.
+ */
+void
+pg_plan_advice_remove_advisor(pg_plan_advice_advisor_hook hook)
+{
+	MemoryContext oldcontext;
+
+	oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+	advisor_hook_list = list_delete_ptr(advisor_hook_list, hook);
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Other loadable modules can use this function to trigger advice generation.
+ *
+ * Calling this function with activate = true requests that any queries
+ * planned afterwards should generate plan advice, which will be stored in the
+ * PlannedStmt. Calling this function with activate = false revokes that
+ * request. Multiple loadable modules could be using this simultaneously, so
+ * make sure to only revoke your own requests.
+ *
+ * Note that you can't use this function to *suppress* advice generation,
+ * which can occur for other reasons, such as the use of EXPLAIN (PLAN_ADVICE),
+ * regardless. It's a way of turning advice generation on, not a way of turning
+ * it off.
+ */
+void
+pg_plan_advice_request_advice_generation(bool activate)
+{
+	if (activate)
+		pgpa_planner_generate_advice++;
+	else
+	{
+		Assert(pgpa_planner_generate_advice > 0);
+		pgpa_planner_generate_advice--;
+	}
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+									  ParseState *pstate)
+{
+	bool	   *plan_advice;
+
+	plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+	if (plan_advice == NULL)
+	{
+		plan_advice = palloc0_object(bool);
+		SetExplainExtensionState(es, es_extension_id, plan_advice);
+	}
+
+	*plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+									  char *value)
+{
+	char	   *s;
+
+	/* For non-text formats, it's best not to add any special handling. */
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		ExplainPropertyText(qlabel, value, es);
+		return;
+	}
+
+	/* In text format, if there is no data, display nothing. */
+	if (*value == '\0')
+		return;
+
+	/*
+	 * It looks nicest to indent each line of the advice separately, beginning
+	 * on the line below the label.
+	 */
+	ExplainIndentText(es);
+	appendStringInfo(es->str, "%s:\n", qlabel);
+	es->indent++;
+	while ((s = strchr(value, '\n')) != NULL)
+	{
+		ExplainIndentText(es);
+		appendBinaryStringInfo(es->str, value, (s - value) + 1);
+		value = s + 1;
+	}
+
+	/* Don't interpret a terminal newline as a request for an empty line. */
+	if (*value != '\0')
+	{
+		ExplainIndentText(es);
+		appendStringInfo(es->str, "%s\n", value);
+	}
+
+	es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		appendStringInfo(&buf, "%s /* ", item->defname);
+		pgpa_trove_append_flags(&buf, flags);
+		appendStringInfo(&buf, " */\n");
+	}
+
+	pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+										  buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+									 IntoClause *into,
+									 ExplainState *es,
+									 const char *queryString,
+									 ParamListInfo params,
+									 QueryEnvironment *queryEnv)
+{
+	bool		should_explain;
+	DefElem    *pgpa_item;
+	List	   *pgpa_list;
+
+	if (prev_explain_per_plan)
+		prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+							  queryEnv);
+
+	/* Should an advice string be part of the EXPLAIN output? */
+	should_explain = pg_plan_advice_should_explain(es);
+
+	/* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+	pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+										"pg_plan_advice");
+	pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+	/*
+	 * By default, if there is a record of attempting to apply advice during
+	 * query planning, we always output that information, but the user can set
+	 * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+	 * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+	 * was specified and not set to false.
+	 *
+	 * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+	 * statement -- the application of query advice may not have been
+	 * recorded, and therefore this won't be able to show anything. Use
+	 * pg_plan_advice.always_store_advice_details = true to work around this.
+	 */
+	if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+							  should_explain))
+	{
+		DefElem    *feedback;
+
+		feedback = find_defelem_by_defname(pgpa_list, "feedback");
+		if (feedback != NULL)
+			pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+	}
+
+	/*
+	 * If the PLAN_ADVICE option was specified -- and not set to FALSE --
+	 * show generated advice.
+	 */
+	if (should_explain)
+	{
+		DefElem    *advice_string_item;
+		char	   *advice_string = NULL;
+
+		advice_string_item =
+			find_defelem_by_defname(pgpa_list, "advice_string");
+		if (advice_string_item != NULL)
+		{
+			advice_string = strVal(advice_string_item->arg);
+			pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+												  advice_string);
+		}
+	}
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+	MemoryContext oldcontext;
+	MemoryContext tmpcontext;
+	char	   *error;
+
+	if (*newval == NULL)
+		return true;
+
+	tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+									   "pg_plan_advice.advice",
+									   ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+	/*
+	 * It would be nice to save the parse tree that we construct here for
+	 * eventual use when planning with this advice, but *extra can only point
+	 * to a single guc_malloc'd chunk, and our parse tree involves an
+	 * arbitrary number of memory allocations.
+	 */
+	(void) pgpa_parse(*newval, &error);
+
+	if (error != NULL)
+		GUC_check_errdetail("Could not parse advice: %s", error);
+
+	MemoryContextSwitchTo(oldcontext);
+	MemoryContextDelete(tmpcontext);
+
+	return (error == NULL);
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
new file mode 100644
index 00000000000..749331b6b8a
--- /dev/null
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ *	  main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "commands/explain_state.h"
+#include "nodes/pathnodes.h"
+
+/* Hook for other plugins to supply advice strings */
+typedef char *(*pg_plan_advice_advisor_hook) (PlannerGlobal *glob,
+											  Query *parse,
+											  const char *query_string,
+											  int cursorOptions,
+											  ExplainState *es);
+
+/* GUC variables */
+extern char *pg_plan_advice_advice;
+extern bool pg_plan_advice_always_store_advice_details;
+extern bool pg_plan_advice_feedback_warnings;
+extern bool pg_plan_advice_trace_mask;
+
+/* Function prototypes (for use by pg_plan_advice itself) */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern bool pg_plan_advice_should_explain(ExplainState *es);
+extern char *pg_plan_advice_get_supplied_query_advice(PlannerGlobal *glob,
+													  Query *parse,
+													  const char *query_string,
+													  int cursorOptions,
+													  ExplainState *es);
+
+/* Function prototypes (for use by other plugins) */
+extern PGDLLEXPORT void pg_plan_advice_add_advisor(pg_plan_advice_advisor_hook hook);
+extern PGDLLEXPORT void pg_plan_advice_remove_advisor(pg_plan_advice_advisor_hook hook);
+extern PGDLLEXPORT void pg_plan_advice_request_advice_generation(bool activate);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_ast.c b/contrib/pg_plan_advice/pgpa_ast.c
new file mode 100644
index 00000000000..f4fa6a626d4
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.c
@@ -0,0 +1,351 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ *	  additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+										  pgpa_advice_target *target,
+										  bool *rids_used);
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+	switch (advice_tag)
+	{
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_TAG_FOREIGN_JOIN:
+			return "FOREIGN_JOIN";
+		case PGPA_TAG_GATHER:
+			return "GATHER";
+		case PGPA_TAG_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPA_TAG_HASH_JOIN:
+			return "HASH_JOIN";
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_TAG_INDEX_SCAN:
+			return "INDEX_SCAN";
+		case PGPA_TAG_JOIN_ORDER:
+			return "JOIN_ORDER";
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case PGPA_TAG_NO_GATHER:
+			return "NO_GATHER";
+		case PGPA_TAG_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+		case PGPA_TAG_SEQ_SCAN:
+			return "SEQ_SCAN";
+		case PGPA_TAG_TID_SCAN:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and return an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+	*fail = false;
+
+	switch (tag[0])
+	{
+		case 'b':
+			if (strcmp(tag, "bitmap_heap_scan") == 0)
+				return PGPA_TAG_BITMAP_HEAP_SCAN;
+			break;
+		case 'f':
+			if (strcmp(tag, "foreign_join") == 0)
+				return PGPA_TAG_FOREIGN_JOIN;
+			break;
+		case 'g':
+			if (strcmp(tag, "gather") == 0)
+				return PGPA_TAG_GATHER;
+			if (strcmp(tag, "gather_merge") == 0)
+				return PGPA_TAG_GATHER_MERGE;
+			break;
+		case 'h':
+			if (strcmp(tag, "hash_join") == 0)
+				return PGPA_TAG_HASH_JOIN;
+			break;
+		case 'i':
+			if (strcmp(tag, "index_scan") == 0)
+				return PGPA_TAG_INDEX_SCAN;
+			if (strcmp(tag, "index_only_scan") == 0)
+				return PGPA_TAG_INDEX_ONLY_SCAN;
+			break;
+		case 'j':
+			if (strcmp(tag, "join_order") == 0)
+				return PGPA_TAG_JOIN_ORDER;
+			break;
+		case 'm':
+			if (strcmp(tag, "merge_join_materialize") == 0)
+				return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+			if (strcmp(tag, "merge_join_plain") == 0)
+				return PGPA_TAG_MERGE_JOIN_PLAIN;
+			break;
+		case 'n':
+			if (strcmp(tag, "nested_loop_materialize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+			if (strcmp(tag, "nested_loop_memoize") == 0)
+				return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+			if (strcmp(tag, "nested_loop_plain") == 0)
+				return PGPA_TAG_NESTED_LOOP_PLAIN;
+			if (strcmp(tag, "no_gather") == 0)
+				return PGPA_TAG_NO_GATHER;
+			break;
+		case 'p':
+			if (strcmp(tag, "partitionwise") == 0)
+				return PGPA_TAG_PARTITIONWISE;
+			break;
+		case 's':
+			if (strcmp(tag, "semijoin_non_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+			if (strcmp(tag, "semijoin_unique") == 0)
+				return PGPA_TAG_SEMIJOIN_UNIQUE;
+			if (strcmp(tag, "seq_scan") == 0)
+				return PGPA_TAG_SEQ_SCAN;
+			break;
+		case 't':
+			if (strcmp(tag, "tid_scan") == 0)
+				return PGPA_TAG_TID_SCAN;
+			break;
+	}
+
+	/* didn't work out */
+	*fail = true;
+
+	/* return an arbitrary value to unwind the call stack */
+	return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		bool		first = true;
+		char	   *delims;
+
+		if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+			delims = "{}";
+		else
+			delims = "()";
+
+		appendStringInfoChar(str, delims[0]);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (first)
+				first = false;
+			else
+				appendStringInfoChar(str, ' ');
+			pgpa_format_advice_target(str, child_target);
+		}
+		appendStringInfoChar(str, delims[1]);
+	}
+	else
+	{
+		const char *rt_identifier;
+
+		rt_identifier = pgpa_identifier_string(&target->rid);
+		appendStringInfoString(str, rt_identifier);
+	}
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+	if (itarget->indnamespace != NULL)
+		appendStringInfo(str, "%s.",
+						 quote_identifier(itarget->indnamespace));
+	appendStringInfoString(str, quote_identifier(itarget->indname));
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+	/* indnamespace can be NULL, and two NULL values are equal */
+	if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+		(i1->indnamespace == NULL || i2->indnamespace == NULL ||
+		 strcmp(i1->indnamespace, i2->indnamespace) != 0))
+		return false;
+	if (strcmp(i1->indname, i2->indname) != 0)
+		return false;
+
+	return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+	/* For non-identifiers, check all descendants. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (pgpa_identifier_matches_target(rid, child_target))
+				return true;
+		}
+		return false;
+	}
+
+	/* Straightforward comparisons of alias name and occurrence number. */
+	if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+		return false;
+	if (rid->occurrence != target->rid.occurrence)
+		return false;
+
+	/*
+	 * If a relation identifier mentions a partition name, it should also
+	 * specify a partition schema. But the target may leave the schema NULL to
+	 * match anything.
+	 */
+	Assert(rid->partnsp != NULL || rid->partrel == NULL);
+	if (rid->partnsp != NULL && target->rid.partnsp != NULL &&
+		strcmp(rid->partnsp, target->rid.partnsp) != 0)
+		return false;
+
+	/*
+	 * These fields can be NULL on either side, but NULL only matches another
+	 * NULL.
+	 */
+	if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+		return false;
+	if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+		return false;
+
+	return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target)
+{
+	bool		all_rids_used = true;
+	bool		any_rids_used = false;
+	bool		all_targets_used;
+	bool	   *rids_used = palloc0_array(bool, nrids);
+
+	all_targets_used =
+		pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+	for (int i = 0; i < nrids; ++i)
+	{
+		if (rids_used[i])
+			any_rids_used = true;
+		else
+			all_rids_used = false;
+	}
+
+	if (all_rids_used)
+	{
+		if (all_targets_used)
+			return PGPA_ITM_EQUAL;
+		else
+			return PGPA_ITM_KEYS_ARE_SUBSET;
+	}
+	else
+	{
+		if (all_targets_used)
+			return PGPA_ITM_TARGETS_ARE_SUBSET;
+		else if (any_rids_used)
+			return PGPA_ITM_INTERSECTING;
+		else
+			return PGPA_ITM_DISJOINT;
+	}
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+							  pgpa_advice_target *target, bool *rids_used)
+{
+	bool		result = false;
+
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		result = true;
+
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+											   rids_used))
+				result = false;
+		}
+	}
+	else
+	{
+		for (int i = 0; i < nrids; ++i)
+		{
+			if (pgpa_identifier_matches_target(&rids[i], target))
+			{
+				rids_used[i] = true;
+				result = true;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_ast.h b/contrib/pg_plan_advice/pgpa_ast.h
new file mode 100644
index 00000000000..3c3db801926
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_ast.h
@@ -0,0 +1,185 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ *	  abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+	PGPA_TARGET_IDENTIFIER,		/* relation identifier */
+	PGPA_TARGET_ORDERED_LIST,	/* (item ...) */
+	PGPA_TARGET_UNORDERED_LIST	/* {item ...} */
+} pgpa_target_type;
+
+/*
+ * An index specification.
+ */
+typedef struct pgpa_index_target
+{
+	/* Index schema and name */
+	char	   *indnamespace;
+	char	   *indname;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+	pgpa_target_type ttype;
+
+	/*
+	 * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+	 *
+	 * All identifiers must have an alias name and an occurrence number; the
+	 * remaining fields can be NULL. Note that it's possible to specify a
+	 * partition name without a partition schema, but not the reverse.
+	 */
+	pgpa_identifier rid;
+
+	/*
+	 * This field is set when ttype is PGPA_TARGET_IDENTIFIER and the advice
+	 * tag is PGPA_TAG_INDEX_SCAN or PGPA_TAG_INDEX_ONLY_SCAN.
+	 */
+	pgpa_index_target *itarget;
+
+	/*
+	 * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+	 * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+	 */
+	List	   *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+	PGPA_TAG_BITMAP_HEAP_SCAN,
+	PGPA_TAG_FOREIGN_JOIN,
+	PGPA_TAG_GATHER,
+	PGPA_TAG_GATHER_MERGE,
+	PGPA_TAG_HASH_JOIN,
+	PGPA_TAG_INDEX_ONLY_SCAN,
+	PGPA_TAG_INDEX_SCAN,
+	PGPA_TAG_JOIN_ORDER,
+	PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+	PGPA_TAG_MERGE_JOIN_PLAIN,
+	PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+	PGPA_TAG_NESTED_LOOP_MEMOIZE,
+	PGPA_TAG_NESTED_LOOP_PLAIN,
+	PGPA_TAG_NO_GATHER,
+	PGPA_TAG_PARTITIONWISE,
+	PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+	PGPA_TAG_SEMIJOIN_UNIQUE,
+	PGPA_TAG_SEQ_SCAN,
+	PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+	pgpa_advice_tag_type tag;
+	List	   *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifiers and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifiers and targets were
+ * found.
+ */
+typedef enum
+{
+	PGPA_ITM_EQUAL,
+	PGPA_ITM_KEYS_ARE_SUBSET,
+	PGPA_ITM_TARGETS_ARE_SUBSET,
+	PGPA_ITM_INTERSECTING,
+	PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int	pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+					   char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner,
+						 const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int	pgpa_yyparse(List **result, char **parse_error_msg_p,
+						 yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+										   pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+												   pgpa_identifier *rids,
+												   pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+									 pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+									  pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+									 pgpa_index_target *itarget);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_identifier.c b/contrib/pg_plan_advice/pgpa_identifier.c
new file mode 100644
index 00000000000..0cfc4aa4f7e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.c
@@ -0,0 +1,481 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ *	  create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted.  Identifier-style escaping is applied to alias_name and
+ * subquery_name.  In generated advice, child table names are always
+ * schema-qualified, but users can supply advice where the schema name is
+ * not mentioned. Identifier-style escaping is applied to the schema and to
+ * the relation name separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * NB: All of this code must use rt_fetch(), not planner_rt_fetch()!
+ * Join removal and self-join elimination remove rels from the arrays
+ * that planner_rt_fetch() uses; using rt_fetch() is necessary to get
+ * stable results.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+									  List *appinfos);
+static int	pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+								   SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+	const char *result;
+
+	Assert(rid->alias_name != NULL);
+	result = quote_identifier(rid->alias_name);
+
+	Assert(rid->occurrence >= 0);
+	if (rid->occurrence > 1)
+		result = psprintf("%s#%d", result, rid->occurrence);
+
+	if (rid->partrel != NULL)
+	{
+		if (rid->partnsp == NULL)
+			result = psprintf("%s/%s", result,
+							  quote_identifier(rid->partrel));
+		else
+			result = psprintf("%s/%s.%s", result,
+							  quote_identifier(rid->partnsp),
+							  quote_identifier(rid->partrel));
+	}
+
+	if (rid->plan_name != NULL)
+		result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+	return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+							   pgpa_identifier *rid)
+{
+	Index		top_rti = rti;
+	int			occurrence = 1;
+	RangeTblEntry *rte;
+	RangeTblEntry *top_rte;
+	char	   *partnsp = NULL;
+	char	   *partrel = NULL;
+
+	/*
+	 * If this is a child RTE, find the topmost parent that is still of type
+	 * RTE_RELATION. We do this because we identify children of partitioned
+	 * tables by the name of the child table, but subqueries can also have
+	 * child rels and we don't care about those here.
+	 */
+	for (;;)
+	{
+		AppendRelInfo *appinfo;
+		RangeTblEntry *parent_rte;
+
+		/* append_rel_array can be NULL if there are no children */
+		if (root->append_rel_array == NULL ||
+			(appinfo = root->append_rel_array[top_rti]) == NULL)
+			break;
+
+		parent_rte = rt_fetch(appinfo->parent_relid, root->parse->rtable);
+		if (parent_rte->rtekind != RTE_RELATION)
+			break;
+
+		top_rti = appinfo->parent_relid;
+	}
+
+	/* Get the range table entries for the RTI and top RTI. */
+	rte = rt_fetch(rti, root->parse->rtable);
+	top_rte = rt_fetch(top_rti, root->parse->rtable);
+	Assert(rte->rtekind != RTE_JOIN);
+	Assert(top_rte->rtekind != RTE_JOIN);
+
+	/* Work out the correct occurrence number. */
+	for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+		AppendRelInfo *appinfo;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 *
+		 * NB: append_rel_array can be NULL if there are no children
+		 */
+		if (root->append_rel_array != NULL &&
+			(appinfo = root->append_rel_array[prior_rti]) != NULL)
+		{
+			RangeTblEntry *parent_rte;
+
+			parent_rte = rt_fetch(appinfo->parent_relid, root->parse->rtable);
+			if (parent_rte->rtekind == RTE_RELATION)
+				continue;
+		}
+
+		/* Skip NULL entries and joins. */
+		prior_rte = rt_fetch(prior_rti, root->parse->rtable);
+		if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	/* If this is a child table, get the schema and relation names. */
+	if (rti != top_rti)
+	{
+		partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+		partrel = get_rel_name(rte->relid);
+	}
+
+	/* OK, we have all the answers we need. Return them to the caller. */
+	rid->alias_name = top_rte->eref->aliasname;
+	rid->occurrence = occurrence;
+	rid->partnsp = partnsp;
+	rid->partrel = partrel;
+	rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+								   pgpa_identifier *rids)
+{
+	int			count = 0;
+	int			rti = -1;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, root->parse->rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+		pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+	}
+
+	Assert(count > 0);
+	return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+	Index		rtable_length = list_length(pstmt->rtable);
+	pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+	Index	   *top_rti_map;
+	int			rtinfoindex = 0;
+	SubPlanRTInfo *rtinfo = NULL;
+	SubPlanRTInfo *nextrtinfo = NULL;
+
+	/*
+	 * Account for relations added by inheritance expansion of partitioned
+	 * tables.
+	 */
+	top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+										  pstmt->appendRelations);
+
+	/*
+	 * When we begin iterating, we're processing the portion of the range
+	 * table that originated from the top-level PlannerInfo, so subrtinfo is
+	 * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+	 * portion of the range table we are processing. nextrtinfo is always the
+	 * SubPlanRTInfo that follows the current one, if any, so when we're
+	 * processing the top-level query's portion of the range table, the next
+	 * SubPlanRTInfo is the very first one.
+	 */
+	if (pstmt->subrtinfos != NULL)
+		nextrtinfo = linitial(pstmt->subrtinfos);
+
+	/* Main loop over the range table. */
+	for (Index rti = 1; rti <= rtable_length; rti++)
+	{
+		const char *plan_name;
+		Index		top_rti;
+		RangeTblEntry *rte;
+		RangeTblEntry *top_rte;
+		char	   *partnsp = NULL;
+		char	   *partrel = NULL;
+		int			occurrence;
+		pgpa_identifier *rid;
+
+		/*
+		 * Advance to the next SubPlanRTInfo, if it's time to do that.
+		 *
+		 * This loop probably shouldn't ever iterate more than once, because
+		 * that would imply that a subquery was planned but added nothing to
+		 * the range table; but let's be defensive and assume it can happen.
+		 */
+		while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+		{
+			rtinfo = nextrtinfo;
+			if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+				nextrtinfo = NULL;
+			else
+				nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+		}
+
+		/* Fetch the range table entry, if any. */
+		rte = rt_fetch(rti, pstmt->rtable);
+
+		/*
+		 * We can't and don't need to identify null entries, and we don't want
+		 * to identify join entries.
+		 */
+		if (rte == NULL || rte->rtekind == RTE_JOIN)
+			continue;
+
+		/*
+		 * If this is not a relation added by partitioned table expansion,
+		 * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+		 * we need the information for the top RTI/RTE, and must also fetch
+		 * the partition schema and name.
+		 */
+		top_rti = top_rti_map[rti - 1];
+		if (rti == top_rti)
+			top_rte = rte;
+		else
+		{
+			top_rte = rt_fetch(top_rti, pstmt->rtable);
+			partnsp =
+				get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+			partrel = get_rel_name(rte->relid);
+		}
+
+		/* Compute the correct occurrence number. */
+		occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+											rtinfo, top_rti);
+
+		/* Get the name of the current plan (NULL for toplevel query). */
+		plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+		/* Save all the details we've derived. */
+		rid = &result[rti - 1];
+		rid->alias_name = top_rte->eref->aliasname;
+		rid->occurrence = occurrence;
+		rid->partnsp = partnsp;
+		rid->partrel = partrel;
+		rid->plan_name = plan_name;
+	}
+
+	return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+								 pgpa_identifier *rt_identifiers,
+								 pgpa_identifier *rid)
+{
+	Index		result = 0;
+
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+	{
+		pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+		/* If there's no identifier for this RTI, skip it. */
+		if (rti_rid->alias_name == NULL)
+			continue;
+
+		/*
+		 * If it matches, return this RTI. As usual, an omitted partition
+		 * schema matches anything, but partition and plan names must either
+		 * match exactly or be omitted on both sides.
+		 */
+		if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+			rid->occurrence == rti_rid->occurrence &&
+			(rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+			 strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+			strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+			strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+		{
+			if (result != 0)
+			{
+				/* Multiple matches were found. */
+				return 0;
+			}
+			result = rti;
+		}
+	}
+
+	return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+	Index	   *top_rti_map = palloc0_array(Index, rtable_length);
+
+	/* Initially, make every RTI point to itself. */
+	for (Index rti = 1; rti <= rtable_length; ++rti)
+		top_rti_map[rti - 1] = rti;
+
+	/* Update the map for each AppendRelInfo object. */
+	foreach_node(AppendRelInfo, appinfo, appinfos)
+	{
+		Index		parent_rti = appinfo->parent_relid;
+		RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+		/* If the parent is not RTE_RELATION, ignore this entry. */
+		if (parent_rte->rtekind != RTE_RELATION)
+			continue;
+
+		/*
+		 * Map the child to wherever we mapped the parent. Parents always
+		 * precede their children in the AppendRelInfo list, so this should
+		 * work out.
+		 */
+		top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+	}
+
+	return top_rti_map;
+}
+
+/*
+ * Find the occurrence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurrence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+					   SubPlanRTInfo *rtinfo, Index rti)
+{
+	Index		rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+	int			occurrence = 1;
+	RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+	for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+	{
+		RangeTblEntry *prior_rte;
+
+		/*
+		 * If this is a child rel of a parent that is a relation, skip it.
+		 *
+		 * Such range table entries are disambiguated by mentioning the schema
+		 * and name of the table, not by counting them as separate occurrences
+		 * of the same table.
+		 */
+		if (top_rti_map[prior_rti - 1] != prior_rti)
+			continue;
+
+		/* Skip joins. */
+		prior_rte = rt_fetch(prior_rti, rtable);
+		if (prior_rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Skip if the alias name differs. */
+		if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+			continue;
+
+		/* Looks like a true duplicate. */
+		++occurrence;
+	}
+
+	return occurrence;
+}
diff --git a/contrib/pg_plan_advice/pgpa_identifier.h b/contrib/pg_plan_advice/pgpa_identifier.h
new file mode 100644
index 00000000000..393a83dc78c
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_identifier.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ *	  create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+	const char *alias_name;
+	int			occurrence;
+	const char *partnsp;
+	const char *partrel;
+	const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+	if (a == b)
+		return true;
+	else if (a == NULL || b == NULL)
+		return false;
+	else
+		return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+										   pgpa_identifier *rid);
+extern int	pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+											   Bitmapset *relids,
+											   pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+											  pgpa_identifier *rt_identifiers,
+											  pgpa_identifier *rid);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
new file mode 100644
index 00000000000..4610d02356f
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -0,0 +1,638 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	Plan	   *outer_subplan;
+	ElidedNode *outer_elided_node;
+	bool		outer_beneath_any_gather;
+	pgpa_join_strategy *strategy;
+	Plan	  **inner_subplans;
+	ElidedNode **inner_elided_nodes;
+	pgpa_join_unroller **inner_unrollers;
+	bool	   *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+											  Plan *plan,
+											  Plan **realouter,
+											  Plan **realinner,
+											  ElidedNode **elidedrealouter,
+											  ElidedNode **elidedrealinner,
+											  bool *found_any_outer_gather,
+											  bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+										   bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+									ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+	pgpa_join_unroller *join_unroller;
+
+	join_unroller = palloc0_object(pgpa_join_unroller);
+	join_unroller->nallocated = 4;
+	join_unroller->strategy =
+		palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+	join_unroller->inner_subplans =
+		palloc_array(Plan *, join_unroller->nallocated);
+	join_unroller->inner_elided_nodes =
+		palloc_array(ElidedNode *, join_unroller->nallocated);
+	join_unroller->inner_unrollers =
+		palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+	join_unroller->inner_beneath_any_gather =
+		palloc_array(bool, join_unroller->nallocated);
+
+	return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+				 bool beneath_any_gather,
+				 pgpa_join_unroller *join_unroller,
+				 pgpa_join_unroller **outer_join_unroller,
+				 pgpa_join_unroller **inner_join_unroller)
+{
+	pgpa_join_strategy strategy;
+	Plan	   *realinner,
+			   *realouter;
+	ElidedNode *elidedinner,
+			   *elidedouter;
+	int			n;
+	bool		found_any_outer_gather = false;
+	bool		found_any_inner_gather = false;
+
+	Assert(join_unroller != NULL);
+
+	/*
+	 * We need to pass the join_unroller object down through certain types of
+	 * plan nodes -- anything that's considered part of the join strategy, and
+	 * any other nodes that can occur in a join tree despite not being scans
+	 * or joins.
+	 *
+	 * This includes:
+	 *
+	 * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+	 * strategy,
+	 *
+	 * (2) Gather and Gather Merge nodes, which can occur at any point in the
+	 * join tree where the planner decided to initiate parallelism,
+	 *
+	 * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+	 * or GatherMerge,
+	 *
+	 * (4) Agg and Unique nodes, which can occur when we decide to make the
+	 * nullable side of a semijoin unique and then join the result, and
+	 *
+	 * (5) Result nodes with children, which can be added either to project to
+	 * enforce a one-time filter (but Result nodes without children are
+	 * degenerate scans or joins).
+	 */
+	if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+		|| IsA(plan, Gather) || IsA(plan, GatherMerge)
+		|| is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+		|| is_result_node_with_child(plan))
+	{
+		*outer_join_unroller = join_unroller;
+		return;
+	}
+
+	/*
+	 * Since we've already handled nodes that require pass-through treatment,
+	 * this should be an unrollable join.
+	 */
+	strategy = pgpa_decompose_join(walker, plan,
+								   &realouter, &realinner,
+								   &elidedouter, &elidedinner,
+								   &found_any_outer_gather,
+								   &found_any_inner_gather);
+
+	/* If our workspace is full, expand it. */
+	if (join_unroller->nused >= join_unroller->nallocated)
+	{
+		join_unroller->nallocated *= 2;
+		join_unroller->strategy =
+			repalloc_array(join_unroller->strategy,
+						   pgpa_join_strategy,
+						   join_unroller->nallocated);
+		join_unroller->inner_subplans =
+			repalloc_array(join_unroller->inner_subplans,
+						   Plan *,
+						   join_unroller->nallocated);
+		join_unroller->inner_elided_nodes =
+			repalloc_array(join_unroller->inner_elided_nodes,
+						   ElidedNode *,
+						   join_unroller->nallocated);
+		join_unroller->inner_beneath_any_gather =
+			repalloc_array(join_unroller->inner_beneath_any_gather,
+						   bool,
+						   join_unroller->nallocated);
+		join_unroller->inner_unrollers =
+			repalloc_array(join_unroller->inner_unrollers,
+						   pgpa_join_unroller *,
+						   join_unroller->nallocated);
+	}
+
+	/*
+	 * Since we're flattening outer-deep join trees, it follows that if the
+	 * outer side is still an unrollable join, it should be unrolled into this
+	 * same object. Otherwise, we've reached the limit of what we can unroll
+	 * into this object and must remember the outer side as the final outer
+	 * subplan.
+	 */
+	if (elidedouter == NULL && pgpa_is_join(realouter))
+		*outer_join_unroller = join_unroller;
+	else
+	{
+		join_unroller->outer_subplan = realouter;
+		join_unroller->outer_elided_node = elidedouter;
+		join_unroller->outer_beneath_any_gather =
+			beneath_any_gather || found_any_outer_gather;
+	}
+
+	/*
+	 * Store the inner subplan. If it's an unrollable join, it needs to be
+	 * flattened in turn, but into a new unroller object, not this one.
+	 */
+	n = join_unroller->nused++;
+	join_unroller->strategy[n] = strategy;
+	join_unroller->inner_subplans[n] = realinner;
+	join_unroller->inner_elided_nodes[n] = elidedinner;
+	join_unroller->inner_beneath_any_gather[n] =
+		beneath_any_gather || found_any_inner_gather;
+	if (elidedinner == NULL && pgpa_is_join(realinner))
+		*inner_join_unroller = pgpa_create_join_unroller();
+	else
+		*inner_join_unroller = NULL;
+	join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+						 pgpa_join_unroller *join_unroller)
+{
+	pgpa_unrolled_join *ujoin;
+	int			i;
+
+	/*
+	 * We shouldn't have gone even so far as to create a join unroller unless
+	 * we found at least one unrollable join.
+	 */
+	Assert(join_unroller->nused > 0);
+
+	/* Allocate result structures. */
+	ujoin = palloc0_object(pgpa_unrolled_join);
+	ujoin->ninner = join_unroller->nused;
+	ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+	ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+	/* Handle the outermost join. */
+	ujoin->outer.plan = join_unroller->outer_subplan;
+	ujoin->outer.elided_node = join_unroller->outer_elided_node;
+	ujoin->outer.scan =
+		pgpa_build_scan(walker, ujoin->outer.plan,
+						ujoin->outer.elided_node,
+						join_unroller->outer_beneath_any_gather,
+						true);
+
+	/*
+	 * We want the joins from the deepest part of the plan tree to appear
+	 * first in the result object, but the join unroller adds them in exactly
+	 * the reverse of that order, so we need to flip the order of the arrays
+	 * when constructing the final result.
+	 */
+	for (i = 0; i < join_unroller->nused; ++i)
+	{
+		int			k = join_unroller->nused - i - 1;
+
+		/* Copy strategy, Plan, and ElidedNode. */
+		ujoin->strategy[i] = join_unroller->strategy[k];
+		ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+		ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+		/*
+		 * Fill in remaining details, using either the nested join unroller,
+		 * or by deriving them from the plan and elided nodes.
+		 */
+		if (join_unroller->inner_unrollers[k] != NULL)
+			ujoin->inner[i].unrolled_join =
+				pgpa_build_unrolled_join(walker,
+										 join_unroller->inner_unrollers[k]);
+		else
+			ujoin->inner[i].scan =
+				pgpa_build_scan(walker, ujoin->inner[i].plan,
+								ujoin->inner[i].elided_node,
+								join_unroller->inner_beneath_any_gather[k],
+								true);
+	}
+
+	return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+	pfree(join_unroller->strategy);
+	pfree(join_unroller->inner_subplans);
+	pfree(join_unroller->inner_elided_nodes);
+	pfree(join_unroller->inner_unrollers);
+	pfree(join_unroller->inner_beneath_any_gather);
+	pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort node on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any corresponding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+					Plan **realouter, Plan **realinner,
+					ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+					bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+	PlannedStmt *pstmt = walker->pstmt;
+	JoinType	jointype = ((Join *) plan)->jointype;
+	Plan	   *outerplan = plan->lefttree;
+	Plan	   *innerplan = plan->righttree;
+	ElidedNode *elidedouter;
+	ElidedNode *elidedinner;
+	pgpa_join_strategy strategy;
+	bool		uniqueouter;
+	bool		uniqueinner;
+
+	elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+	elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+	*found_any_outer_gather = false;
+	*found_any_inner_gather = false;
+
+	switch (nodeTag(plan))
+	{
+		case T_MergeJoin:
+
+			/*
+			 * The planner may have chosen to place a Material node on the
+			 * inner side of the MergeJoin; if this is present, we record it
+			 * as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+			}
+			else
+				strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+			/*
+			 * For a MergeJoin, either the outer or the inner subplan, or
+			 * both, may have needed to be sorted; we must disregard any Sort
+			 * or IncrementalSort node to find the real inner or outer
+			 * subplan.
+			 */
+			if (elidedouter == NULL && is_sorting_plan(outerplan))
+				elidedouter = pgpa_descend_node(pstmt, &outerplan);
+			if (elidedinner == NULL && is_sorting_plan(innerplan))
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			break;
+
+		case T_NestLoop:
+
+			/*
+			 * The planner may have chosen to place a Material or Memoize node
+			 * on the inner side of the NestLoop; if this is present, we
+			 * record it as part of the join strategy.
+			 */
+			if (elidedinner == NULL && IsA(innerplan, Material))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+			}
+			else if (elidedinner == NULL && IsA(innerplan, Memoize))
+			{
+				elidedinner = pgpa_descend_node(pstmt, &innerplan);
+				strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+			}
+			else
+				strategy = JSTRAT_NESTED_LOOP_PLAIN;
+			break;
+
+		case T_HashJoin:
+
+			/*
+			 * The inner subplan of a HashJoin is always a Hash node; the real
+			 * inner subplan is the Hash node's child.
+			 */
+			Assert(IsA(innerplan, Hash));
+			Assert(elidedinner == NULL);
+			elidedinner = pgpa_descend_node(pstmt, &innerplan);
+			strategy = JSTRAT_HASH_JOIN;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+	}
+
+	/*
+	 * The planner may have decided to implement a semijoin by first making
+	 * the nullable side of the plan unique, and then performing a normal join
+	 * against the result. Therefore, we might need to descend through a
+	 * unique node on either side of the plan.
+	 */
+	uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+	uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+	/*
+	 * Can we see a Result node here, to project above a Gather? So far I've
+	 * found no example that behaves that way; rather, the Gather or Gather
+	 * Merge is made to project. Hence, don't test is_result_node_with_child()
+	 * at this point.
+	 */
+
+	/*
+	 * The planner may have decided to parallelize part of the join tree, so
+	 * we could find a Gather or Gather Merge node here. Note that, if
+	 * present, this will appear below nodes we considered as part of the join
+	 * strategy, but we could find another uniqueness-enforcing node below the
+	 * Gather or Gather Merge, if present.
+	 */
+	if (elidedouter == NULL)
+	{
+		elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+											  found_any_outer_gather);
+		if (*found_any_outer_gather &&
+			pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+			uniqueouter = true;
+	}
+	if (elidedinner == NULL)
+	{
+		elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+											  found_any_inner_gather);
+		if (*found_any_inner_gather &&
+			pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+			uniqueinner = true;
+	}
+
+	/*
+	 * It's possible that a Result node has been inserted either to project a
+	 * target list or to implement a one-time filter. If so, we can descend
+	 * through it. Note that a Result node without a child would be a
+	 * degenerate scan or join, and not something we could descend through.
+	 */
+	if (elidedouter == NULL && is_result_node_with_child(outerplan))
+		elidedouter = pgpa_descend_node(pstmt, &outerplan);
+	if (elidedinner == NULL && is_result_node_with_child(innerplan))
+		elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+	/*
+	 * If this is a semijoin that was converted to an inner join by making one
+	 * side or the other unique, make a note that the inner or outer subplan,
+	 * as appropriate, should be treated as a query plan feature when the main
+	 * tree traversal reaches it.
+	 *
+	 * Conversely, if the planner could have made one side of the join unique
+	 * and thereby converted it to an inner join, and chose not to do so, that
+	 * is also worth noting.
+	 *
+	 * NB: This code could appear slightly higher up in this function, but
+	 * none of the nodes through which we just descended should have
+	 * associated RTIs.
+	 *
+	 * NB: This seems like a somewhat hacky way of passing information up to
+	 * the main tree walk, but I don't currently have a better idea.
+	 */
+	if (uniqueouter)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+	else if (jointype == JOIN_RIGHT_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+	if (uniqueinner)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+	else if (jointype == JOIN_SEMI)
+		pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+	/* Set output parameters. */
+	*realouter = outerplan;
+	*realinner = innerplan;
+	*elidedrealouter = elidedouter;
+	*elidedrealinner = elidedinner;
+	return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+	*plan = (*plan)->lefttree;
+	return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+						bool *found_any_gather)
+{
+	if (IsA(*plan, Gather))
+	{
+		*found_any_gather = true;
+		return pgpa_descend_node(pstmt, plan);
+	}
+
+	if (IsA(*plan, GatherMerge))
+	{
+		ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+		if (elided == NULL && is_sorting_plan(*plan))
+			elided = pgpa_descend_node(pstmt, plan);
+
+		*found_any_gather = true;
+		return elided;
+	}
+
+	return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through a node that we believe is
+ * making one side of a semijoin unique, and otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+						ElidedNode **elided_node)
+{
+	bool		descend = false;
+	bool		sjunique = false;
+
+	if (*elided_node != NULL)
+		return sjunique;
+
+	if (IsA(*plan, Unique))
+	{
+		descend = true;
+		sjunique = true;
+	}
+	else if (IsA(*plan, Agg))
+	{
+		/*
+		 * If this is a simple Agg node, then assume it's here to implement
+		 * semijoin uniqueness. Otherwise, assume it's completing an eager
+		 * aggregation or partitionwise aggregation operation that began at a
+		 * higher level of the plan tree.
+		 *
+		 * (Note that when we're using an Agg node for uniqueness, there's no
+		 * need for any case other than AGGSPLIT_SIMPLE, because there's no
+		 * aggregated column being computed. However, the fact that
+		 * AGGSPLIT_SIMPLE is in use doesn't prove that this Agg is here for
+		 * the semijoin uniqueness. Maybe we should adjust an Agg node to
+		 * carry a "purpose" field so that code like this can be more certain
+		 * of its analysis.)
+		 */
+		descend = true;
+		sjunique = (((Agg *) *plan)->aggsplit == AGGSPLIT_SIMPLE);
+	}
+
+	if (descend)
+	{
+		*elided_node = pgpa_descend_node(pstmt, plan);
+
+		if (*elided_node == NULL && is_sorting_plan(*plan))
+			*elided_node = pgpa_descend_node(pstmt, plan);
+	}
+
+	return sjunique;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+	return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is to put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+	return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
diff --git a/contrib/pg_plan_advice/pgpa_join.h b/contrib/pg_plan_advice/pgpa_join.h
new file mode 100644
index 00000000000..db8509c07cc
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_join.h
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ *	  analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+	JSTRAT_MERGE_JOIN_PLAIN = 0,
+	JSTRAT_MERGE_JOIN_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_PLAIN,
+	JSTRAT_NESTED_LOOP_MATERIALIZE,
+	JSTRAT_NESTED_LOOP_MEMOIZE,
+	JSTRAT_HASH_JOIN
+	/* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY		((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+	Plan	   *plan;
+	ElidedNode *elided_node;
+	struct pgpa_scan *scan;
+	pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>.  When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+	/* Outermost member; must not itself be an unrolled join. */
+	pgpa_join_member outer;
+
+	/* Number of inner members. Length of the strategy and inner arrays. */
+	unsigned	ninner;
+
+	/* Array of strategies, one per non-outermost member. */
+	pgpa_join_strategy *strategy;
+
+	/* Array of members, excluding the outermost. Deepest first. */
+	pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+	return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+							 Plan *plan, bool beneath_any_gather,
+							 pgpa_join_unroller *join_unroller,
+							 pgpa_join_unroller **outer_join_unroller,
+							 pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+													pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_output.c b/contrib/pg_plan_advice/pgpa_output.c
new file mode 100644
index 00000000000..28d2839ce1a
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.c
@@ -0,0 +1,571 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+	const char **rid_strings;
+	StringInfo	buf;
+	int			wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+									  pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+									pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+									  pgpa_scan_strategy strategy,
+									  List *scans);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+									  pgpa_qf_type type,
+									  List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+										char *strategy,
+										List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+								  Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+								  Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+				   pgpa_identifier *rt_identifiers)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	ListCell   *lc;
+	pgpa_output_context context;
+
+	/* Basic initialization. */
+	memset(&context, 0, sizeof(pgpa_output_context));
+	context.buf = buf;
+
+	/*
+	 * Convert identifiers to string form. Note that the loop variable here is
+	 * not an RTI, because RTIs are 1-based. Some RTIs will have no
+	 * identifier, either because the reloptkind is RTE_JOIN or because that
+	 * portion of the query didn't make it into the final plan.
+	 */
+	context.rid_strings = palloc0_array(const char *, rtable_length);
+	for (int i = 0; i < rtable_length; ++i)
+		if (rt_identifiers[i].alias_name != NULL)
+			context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+	/*
+	 * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+	 * from a psql client with default settings, psql will add one space to
+	 * the left of the output and EXPLAIN will add two more to the left of the
+	 * advice. Thus, lines of more than 77 characters will wrap. We set the
+	 * wrap limit to 76 here so that the output won't reach all the way to the
+	 * very last column of the terminal.
+	 *
+	 * Of course, this is fairly arbitrary set of assumptions, and one could
+	 * well make an argument for a different wrap limit, or for a configurable
+	 * one.
+	 */
+	context.wrap_column = 76;
+
+	/*
+	 * Each piece of JOIN_ORDER() advice fully describes the join order for a
+	 * a single unrolled join. Merging is not permitted, because that would
+	 * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+	 * scans should be used for all of those relations, and is thus equivalent
+	 * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+	 * is the driving table which is then joined to "b" then "c" then "d",
+	 * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+	 */
+	foreach(lc, walker->toplevel_unrolled_joins)
+	{
+		pgpa_unrolled_join *ujoin = lfirst(lc);
+
+		if (buf->len > 0)
+			appendStringInfoChar(buf, '\n');
+		appendStringInfo(context.buf, "JOIN_ORDER(");
+		pgpa_output_unrolled_join(&context, ujoin);
+		appendStringInfoChar(context.buf, ')');
+		pgpa_maybe_linebreak(context.buf, context.wrap_column);
+	}
+
+	/* Emit join strategy advice. */
+	for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+	{
+		char	   *strategy = pgpa_cstring_join_strategy(s);
+
+		pgpa_output_simple_strategy(&context,
+									strategy,
+									walker->join_strategies[s]);
+	}
+
+	/*
+	 * Emit scan strategy advice (but not for ordinary scans, which are
+	 * definitionally uninteresting).
+	 */
+	for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+		if (c != PGPA_SCAN_ORDINARY)
+			pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+	/* Emit query feature advice. */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+		pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+	/* Emit NO_GATHER advice. */
+	pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+						  pgpa_unrolled_join *join)
+{
+	pgpa_output_join_member(context, &join->outer);
+
+	for (int k = 0; k < join->ninner; ++k)
+	{
+		pgpa_join_member *member = &join->inner[k];
+
+		pgpa_maybe_linebreak(context->buf, context->wrap_column);
+		appendStringInfoChar(context->buf, ' ');
+		pgpa_output_join_member(context, member);
+	}
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+						pgpa_join_member *member)
+{
+	if (member->unrolled_join != NULL)
+	{
+		appendStringInfoChar(context->buf, '(');
+		pgpa_output_unrolled_join(context, member->unrolled_join);
+		appendStringInfoChar(context->buf, ')');
+	}
+	else
+	{
+		pgpa_scan  *scan = member->scan;
+
+		Assert(scan != NULL);
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '{');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, '}');
+		}
+	}
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+						  pgpa_scan_strategy strategy,
+						  List *scans)
+{
+	bool		first = true;
+
+	if (scans == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_scan_strategy(strategy));
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		Plan	   *plan = scan->plan;
+
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		/* Output the relation identifiers. */
+		if (bms_membership(scan->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, scan->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, scan->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+
+		/* For index or index-only scans, output index information. */
+		if (strategy == PGPA_SCAN_INDEX)
+		{
+			Assert(IsA(plan, IndexScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+		}
+		else if (strategy == PGPA_SCAN_INDEX_ONLY)
+		{
+			Assert(IsA(plan, IndexOnlyScan));
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+			pgpa_output_relation_name(context,
+									  ((IndexOnlyScan *) plan)->indexid);
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+	Oid			nspoid = get_rel_namespace(relid);
+	char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+	char	   *relname = get_rel_name(relid);
+
+	appendStringInfoString(context->buf, quote_identifier(relnamespace));
+	appendStringInfoChar(context->buf, '.');
+	appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+						  List *query_features)
+{
+	bool		first = true;
+
+	if (query_features == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(",
+					 pgpa_cstring_query_feature_type(type));
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(qf->relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, qf->relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, qf->relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+							List *relid_sets)
+{
+	bool		first = true;
+
+	if (relid_sets == NIL)
+		return;
+
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfo(context->buf, "%s(", strategy);
+
+	foreach_node(Bitmapset, relids, relid_sets)
+	{
+		if (first)
+			first = false;
+		else
+		{
+			pgpa_maybe_linebreak(context->buf, context->wrap_column);
+			appendStringInfoChar(context->buf, ' ');
+		}
+
+		if (bms_membership(relids) == BMS_SINGLETON)
+			pgpa_output_relations(context, context->buf, relids);
+		else
+		{
+			appendStringInfoChar(context->buf, '(');
+			pgpa_output_relations(context, context->buf, relids);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+
+	appendStringInfoChar(context->buf, ')');
+	pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+	if (relids == NULL)
+		return;
+	if (context->buf->len > 0)
+		appendStringInfoChar(context->buf, '\n');
+	appendStringInfoString(context->buf, "NO_GATHER(");
+	pgpa_output_relations(context, context->buf, relids);
+	appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+					  Bitmapset *relids)
+{
+	int			rti = -1;
+	bool		first = true;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		const char *rid_string = context->rid_strings[rti - 1];
+
+		if (rid_string == NULL)
+			elog(ERROR, "no identifier for RTI %d", rti);
+
+		if (first)
+		{
+			first = false;
+			appendStringInfoString(buf, rid_string);
+		}
+		else
+		{
+			pgpa_maybe_linebreak(buf, context->wrap_column);
+			appendStringInfo(buf, " %s", rid_string);
+		}
+	}
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+	switch (strategy)
+	{
+		case JSTRAT_MERGE_JOIN_PLAIN:
+			return "MERGE_JOIN_PLAIN";
+		case JSTRAT_MERGE_JOIN_MATERIALIZE:
+			return "MERGE_JOIN_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_PLAIN:
+			return "NESTED_LOOP_PLAIN";
+		case JSTRAT_NESTED_LOOP_MATERIALIZE:
+			return "NESTED_LOOP_MATERIALIZE";
+		case JSTRAT_NESTED_LOOP_MEMOIZE:
+			return "NESTED_LOOP_MEMOIZE";
+		case JSTRAT_HASH_JOIN:
+			return "HASH_JOIN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+	switch (strategy)
+	{
+		case PGPA_SCAN_ORDINARY:
+			return "ORDINARY_SCAN";
+		case PGPA_SCAN_SEQ:
+			return "SEQ_SCAN";
+		case PGPA_SCAN_BITMAP_HEAP:
+			return "BITMAP_HEAP_SCAN";
+		case PGPA_SCAN_FOREIGN:
+			return "FOREIGN_JOIN";
+		case PGPA_SCAN_INDEX:
+			return "INDEX_SCAN";
+		case PGPA_SCAN_INDEX_ONLY:
+			return "INDEX_ONLY_SCAN";
+		case PGPA_SCAN_PARTITIONWISE:
+			return "PARTITIONWISE";
+		case PGPA_SCAN_TID:
+			return "TID_SCAN";
+	}
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the query feature type.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+	switch (type)
+	{
+		case PGPAQF_GATHER:
+			return "GATHER";
+		case PGPAQF_GATHER_MERGE:
+			return "GATHER_MERGE";
+		case PGPAQF_SEMIJOIN_NON_UNIQUE:
+			return "SEMIJOIN_NON_UNIQUE";
+		case PGPAQF_SEMIJOIN_UNIQUE:
+			return "SEMIJOIN_UNIQUE";
+	}
+
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+	char	   *trailing_nl;
+	int			line_start;
+	int			save_cursor;
+
+	/* If line wrapping is disabled, exit quickly. */
+	if (wrap_column <= 0)
+		return;
+
+	/*
+	 * Set line_start to the byte offset within buf->data of the first
+	 * character of the current line, where the current line means the last
+	 * one in the buffer. Note that line_start could be the offset of the
+	 * trailing '\0' if the last character in the buffer is a line break.
+	 */
+	trailing_nl = strrchr(buf->data, '\n');
+	if (trailing_nl == NULL)
+		line_start = 0;
+	else
+		line_start = (trailing_nl - buf->data) + 1;
+
+	/*
+	 * Remember that the current end of the buffer is a potential location to
+	 * insert a line break on a future call to this function.
+	 */
+	save_cursor = buf->cursor;
+	buf->cursor = buf->len;
+
+	/* If we haven't passed the wrap column, we don't need a newline. */
+	if (buf->len - line_start <= wrap_column)
+		return;
+
+	/*
+	 * It only makes sense to insert a newline at a position later than the
+	 * beginning of the current line.
+	 */
+	if (save_cursor <= line_start)
+		return;
+
+	/* Insert a newline at the previous cursor location. */
+	enlargeStringInfo(buf, 1);
+	memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+			buf->len - save_cursor);
+	++buf->cursor;
+	buf->data[++buf->len] = '\0';
+	buf->data[save_cursor] = '\n';
+}
diff --git a/contrib/pg_plan_advice/pgpa_output.h b/contrib/pg_plan_advice/pgpa_output.h
new file mode 100644
index 00000000000..fd35103bfbd
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_output.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ *	  produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_output.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+							   pgpa_plan_walker_context *walker,
+							   pgpa_identifier *rt_identifiers);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_parser.y b/contrib/pg_plan_advice/pgpa_parser.y
new file mode 100644
index 00000000000..8bfd7666d07
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_parser.y
@@ -0,0 +1,301 @@
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2026, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc.  This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE   pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+	char	   *str;
+	int			integer;
+	List	   *list;
+	pgpa_advice_item *item;
+	pgpa_advice_target *target;
+	pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+		{
+			(void) yynerrs;				/* suppress compiler warning */
+			*result = $1;
+		}
+	;
+
+advice_item_list: advice_item_list advice_item
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = PGPA_TAG_JOIN_ORDER;
+			$$->targets = $3;
+			if ($3 == NIL)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "JOIN_ORDER must have at least one target");
+		}
+	| TOK_TAG_INDEX '(' index_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "index_only_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+			else if (strcmp($1, "index_scan") == 0)
+				$$->tag = PGPA_TAG_INDEX_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_SIMPLE '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_item);
+			if (strcmp($1, "bitmap_heap_scan") == 0)
+				$$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+			else if (strcmp($1, "no_gather") == 0)
+				$$->tag = PGPA_TAG_NO_GATHER;
+			else if (strcmp($1, "seq_scan") == 0)
+				$$->tag = PGPA_TAG_SEQ_SCAN;
+			else if (strcmp($1, "tid_scan") == 0)
+				$$->tag = PGPA_TAG_TID_SCAN;
+			else
+				elog(ERROR, "tag parsing failed: %s", $1);
+			$$->targets = $3;
+		}
+	| TOK_TAG_GENERIC '(' generic_target_list ')'
+		{
+			bool	fail;
+
+			$$ = palloc0_object(pgpa_advice_item);
+			$$->tag = pgpa_parse_advice_tag($1, &fail);
+			if (fail)
+			{
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "unrecognized advice tag");
+			}
+
+			if ($$->tag == PGPA_TAG_FOREIGN_JOIN)
+			{
+				foreach_ptr(pgpa_advice_target, target, $3)
+				{
+					if (target->ttype == PGPA_TARGET_IDENTIFIER ||
+						list_length(target->children) == 1)
+							pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+										 "FOREIGN_JOIN targets must contain more than one relation identifier");
+				}
+			}
+
+			$$->targets = $3;
+		}
+	;
+
+relation_identifier: identifier opt_ri_occurrence opt_partition opt_plan_name
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_IDENTIFIER;
+			$$->rid.alias_name = $1;
+			$$->rid.occurrence = $2;
+			if (list_length($3) == 2)
+			{
+				$$->rid.partnsp = linitial($3);
+				$$->rid.partrel = lsecond($3);
+			}
+			else if ($3 != NIL)
+				$$->rid.partrel = linitial($3);
+			$$->rid.plan_name = $4;
+		}
+	;
+
+index_name: identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->indname = $1;
+		}
+	| identifier '.' identifier
+		{
+			$$ = palloc0_object(pgpa_index_target);
+			$$->indnamespace = $1;
+			$$->indname = $3;
+		}
+	;
+
+opt_ri_occurrence:
+	'#' TOK_INTEGER
+		{
+			if ($2 <= 0)
+				pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+							 "only positive occurrence numbers are permitted");
+			$$ = $2;
+		}
+	|
+		{
+			/* The default occurrence number is 1. */
+			$$ = 1;
+		}
+	;
+
+identifier: TOK_IDENT
+	| TOK_TAG_JOIN_ORDER
+	| TOK_TAG_INDEX
+	| TOK_TAG_SIMPLE
+	| TOK_TAG_GENERIC
+	;
+
+/*
+ * When generating advice, we always schema-qualify the partition name, but
+ * when parsing advice, we accept a specification that lacks one.
+ */
+opt_partition:
+	'/' TOK_IDENT '.' TOK_IDENT
+		{ $$ = list_make2($2, $4); }
+	| '/' TOK_IDENT
+		{ $$ = list_make1($2); }
+	|
+		{ $$ = NIL; }
+	;
+
+opt_plan_name:
+	'@' TOK_IDENT
+		{ $$ = $2; }
+	|
+		{ $$ = NULL; }
+	;
+
+generic_target_list: generic_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| generic_target_list generic_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+generic_sublist: '(' simple_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+index_target_list:
+	  index_target_list relation_identifier index_name
+		{
+			$2->itarget = $3;
+			$$ = lappend($1, $2);
+		}
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_target_list: join_order_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	| join_order_target_list join_order_sublist
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+join_order_sublist:
+	'(' join_order_target_list ')'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_ORDERED_LIST;
+			$$->children = $2;
+		}
+	| '{' simple_target_list '}'
+		{
+			$$ = palloc0_object(pgpa_advice_target);
+			$$->ttype = PGPA_TARGET_UNORDERED_LIST;
+			$$->children = $2;
+		}
+	;
+
+simple_target_list: simple_target_list relation_identifier
+		{ $$ = lappend($1, $2); }
+	|
+		{ $$ = NIL; }
+	;
+
+%%
+
+/*
+ * Parse an advice_string and return the resulting list of pgpa_advice_item
+ * objects. If a parse error occurs, instead return NULL.
+ *
+ * If the return value is NULL, *error_p will be set to the error message;
+ * otherwise, *error_p will be set to NULL.
+ */
+List *
+pgpa_parse(const char *advice_string, char **error_p)
+{
+	yyscan_t	scanner;
+	List	   *result;
+	char	   *error = NULL;
+
+	pgpa_scanner_init(advice_string, &scanner);
+	pgpa_yyparse(&result, &error, scanner);
+	pgpa_scanner_finish(scanner);
+
+	if (error != NULL)
+	{
+		*error_p = error;
+		return NULL;
+	}
+
+	*error_p = NULL;
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
new file mode 100644
index 00000000000..5508b8af707
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -0,0 +1,2198 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.c
+ *	  Use planner hooks to observe and modify planner behavior
+ *
+ * All interaction with the core planner happens here. Much of it has to
+ * do with enforcing supplied advice, but we also need these hooks to
+ * generate advice strings (though the heavy lifting in that case is
+ * mostly done by pgpa_walker.c).
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "common/hashfn_unstable.h"
+#include "nodes/makefuncs.h"
+#include "optimizer/extendplan.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/plancat.h"
+#include "optimizer/planner.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * When assertions are enabled, we try generating relation identifiers during
+ * planning, saving them in a hash table, and then cross-checking them against
+ * the ones generated after planning is complete.
+ */
+typedef struct pgpa_ri_checker_key
+{
+	char	   *plan_name;
+	Index		rti;
+} pgpa_ri_checker_key;
+
+typedef struct pgpa_ri_checker
+{
+	pgpa_ri_checker_key key;
+	uint32		status;
+	const char *rid_string;
+} pgpa_ri_checker;
+
+static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
+
+static inline bool
+pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
+{
+	if (a.rti != b.rti)
+		return false;
+	if (a.plan_name == NULL)
+		return (b.plan_name == NULL);
+	if (b.plan_name == NULL)
+		return false;
+	return strcmp(a.plan_name, b.plan_name) == 0;
+}
+
+#define SH_PREFIX			pgpa_ri_check
+#define SH_ELEMENT_TYPE		pgpa_ri_checker
+#define SH_KEY_TYPE			pgpa_ri_checker_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#endif
+
+typedef enum pgpa_jo_outcome
+{
+	PGPA_JO_PERMITTED,			/* permit this join order */
+	PGPA_JO_DENIED,				/* deny this join order */
+	PGPA_JO_INDIFFERENT			/* do neither */
+} pgpa_jo_outcome;
+
+typedef struct pgpa_planner_state
+{
+	bool		generate_advice_feedback;
+	bool		generate_advice_string;
+	pgpa_trove *trove;
+	List	   *sj_unique_rels;
+
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_check_hash *ri_check_hash;
+#endif
+} pgpa_planner_state;
+
+typedef struct pgpa_join_state
+{
+	/* Most-recently-considered outer rel. */
+	RelOptInfo *outerrel;
+
+	/* Most-recently-considered inner rel. */
+	RelOptInfo *innerrel;
+
+	/*
+	 * Array of relation identifiers for all members of this joinrel, with
+	 * outerrel identifiers before innerrel identifiers.
+	 */
+	pgpa_identifier *rids;
+
+	/* Number of outer rel identifiers. */
+	int			outer_count;
+
+	/* Number of inner rel identifiers. */
+	int			inner_count;
+
+	/*
+	 * Trove lookup results.
+	 *
+	 * join_entries and rel_entries are arrays of entries, and join_indexes
+	 * and rel_indexes are the integer offsets within those arrays of entries
+	 * potentially relevant to us. The "join" fields correspond to a lookup
+	 * using PGPA_TROVE_LOOKUP_JOIN and the "rel" fields to a lookup using
+	 * PGPA_TROVE_LOOKUP_REL.
+	 */
+	pgpa_trove_entry *join_entries;
+	Bitmapset  *join_indexes;
+	pgpa_trove_entry *rel_entries;
+	Bitmapset  *rel_indexes;
+} pgpa_join_state;
+
+/* Saved hook values */
+static build_simple_rel_hook_type prev_build_simple_rel = NULL;
+static join_path_setup_hook_type prev_join_path_setup = NULL;
+static joinrel_setup_hook_type prev_joinrel_setup = NULL;
+static planner_setup_hook_type prev_planner_setup = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+
+/* Other global variables */
+int			pgpa_planner_generate_advice = 0;
+static int	planner_extension_id = -1;
+
+/* Function prototypes. */
+static void pgpa_planner_setup(PlannerGlobal *glob, Query *parse,
+							   const char *query_string,
+							   int cursorOptions,
+							   double *tuple_fraction,
+							   ExplainState *es);
+static void pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string, PlannedStmt *pstmt);
+static void pgpa_build_simple_rel(PlannerInfo *root,
+								  RelOptInfo *rel,
+								  RangeTblEntry *rte);
+static void pgpa_joinrel_setup(PlannerInfo *root,
+							   RelOptInfo *joinrel,
+							   RelOptInfo *outerrel,
+							   RelOptInfo *innerrel,
+							   SpecialJoinInfo *sjinfo,
+							   List *restrictlist);
+static void pgpa_join_path_setup(PlannerInfo *root,
+								 RelOptInfo *joinrel,
+								 RelOptInfo *outerrel,
+								 RelOptInfo *innerrel,
+								 JoinType jointype,
+								 JoinPathExtraData *extra);
+static pgpa_join_state *pgpa_get_join_state(PlannerInfo *root,
+											RelOptInfo *joinrel,
+											RelOptInfo *outerrel,
+											RelOptInfo *innerrel);
+static void pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p,
+											  char *plan_name,
+											  pgpa_join_state *pjs);
+static void pgpa_planner_apply_join_path_advice(JoinType jointype,
+												uint64 *pgs_mask_p,
+												char *plan_name,
+												pgpa_join_state *pjs);
+static void pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+										   pgpa_trove_entry *scan_entries,
+										   Bitmapset *scan_indexes,
+										   pgpa_trove_entry *rel_entries,
+										   Bitmapset *rel_indexes);
+static uint64 pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag);
+static pgpa_jo_outcome pgpa_join_order_permits_join(int outer_count,
+													int inner_count,
+													pgpa_identifier *rids,
+													pgpa_trove_entry *entry);
+static bool pgpa_join_method_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+										  pgpa_identifier *rids,
+										  pgpa_trove_entry *entry,
+										  bool *restrict_method);
+static bool pgpa_semijoin_permits_join(int outer_count, int inner_count,
+									   pgpa_identifier *rids,
+									   pgpa_trove_entry *entry,
+									   bool outer_is_nullable,
+									   bool *restrict_method);
+
+static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+										  pgpa_trove_lookup_type type,
+										  pgpa_identifier *rt_identifiers,
+										  pgpa_plan_walker_context *walker);
+static void pgpa_planner_feedback_warning(List *feedback);
+
+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 char *pgpa_bms_to_cstring(Bitmapset *bms);
+static const char *pgpa_jointype_to_cstring(JoinType jointype);
+
+/*
+ * Install planner-related hooks.
+ */
+void
+pgpa_planner_install_hooks(void)
+{
+	planner_extension_id = GetPlannerExtensionId("pg_plan_advice");
+	prev_planner_setup = planner_setup_hook;
+	planner_setup_hook = pgpa_planner_setup;
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgpa_planner_shutdown;
+	prev_build_simple_rel = build_simple_rel_hook;
+	build_simple_rel_hook = pgpa_build_simple_rel;
+	prev_joinrel_setup = joinrel_setup_hook;
+	joinrel_setup_hook = pgpa_joinrel_setup;
+	prev_join_path_setup = join_path_setup_hook;
+	join_path_setup_hook = pgpa_join_path_setup;
+}
+
+/*
+ * Carry out whatever setup work we need to do before planning.
+ */
+static void
+pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
+				   int cursorOptions, double *tuple_fraction,
+				   ExplainState *es)
+{
+	pgpa_trove *trove = NULL;
+	pgpa_planner_state *pps;
+	char	   *supplied_advice;
+	bool		generate_advice_feedback = false;
+	bool		generate_advice_string = false;
+	bool		needs_pps = false;
+
+	/*
+	 * Decide whether we need to generate an advice string. We must do this if
+	 * the user has told us to do it categorically, or if another loadable
+	 * module has requested it, or if the user has requested it using the
+	 * EXPLAIN (PLAN_ADVICE) option.
+	 */
+	generate_advice_string = (pg_plan_advice_always_store_advice_details ||
+							  pgpa_planner_generate_advice ||
+							  pg_plan_advice_should_explain(es));
+	if (generate_advice_string)
+		needs_pps = true;
+
+	/*
+	 * If any advice was provided, build a trove of advice for use during
+	 * planning.
+	 */
+	supplied_advice = pg_plan_advice_get_supplied_query_advice(glob, parse,
+															   query_string,
+															   cursorOptions,
+															   es);
+	if (supplied_advice != NULL && supplied_advice[0] != '\0')
+	{
+		List	   *advice_items;
+		char	   *error;
+
+		/*
+		 * If the supplied advice string comes from pg_plan_advice.advice,
+		 * parsing shouldn't fail here, because we must have previously parsed
+		 * successfully in pg_plan_advice_advice_check_hook. However, it might
+		 * also come from a hook registered via pg_plan_advice_add_advisor,
+		 * and we can't be sure whether that's valid. (Plus, having an error
+		 * check here seems like a good idea anyway, just for safety.)
+		 */
+		advice_items = pgpa_parse(supplied_advice, &error);
+		if (error)
+			ereport(WARNING,
+					errmsg("could not parse supplied advice: %s", error));
+
+		/*
+		 * It's possible that the advice string was non-empty but contained no
+		 * actual advice, e.g. it was all whitespace.
+		 */
+		if (advice_items != NIL)
+		{
+			trove = pgpa_build_trove(advice_items);
+			needs_pps = true;
+
+			/*
+			 * If we know that we're running under EXPLAIN, or if the user has
+			 * told us to always do the work, generate advice feedback.
+			 */
+			if (es != NULL || pg_plan_advice_feedback_warnings ||
+				pg_plan_advice_always_store_advice_details)
+				generate_advice_feedback = true;
+		}
+	}
+
+#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.
+	 */
+	if (needs_pps)
+	{
+		pps = palloc0_object(pgpa_planner_state);
+		pps->generate_advice_feedback = generate_advice_feedback;
+		pps->generate_advice_string = generate_advice_string;
+		pps->trove = trove;
+#ifdef USE_ASSERT_CHECKING
+		pps->ri_check_hash =
+			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
+#endif
+		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_planner_setup)
+		(*prev_planner_setup) (glob, parse, query_string, cursorOptions,
+							   tuple_fraction, es);
+}
+
+/*
+ * Carry out whatever work we want to do after planning is complete.
+ */
+static void
+pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	pgpa_planner_state *pps;
+	pgpa_trove *trove = NULL;
+	pgpa_plan_walker_context walker = {0};	/* placate compiler */
+	bool		generate_advice_feedback = false;
+	bool		generate_advice_string = false;
+	List	   *pgpa_items = NIL;
+	pgpa_identifier *rt_identifiers = NULL;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
+	if (pps != NULL)
+	{
+		trove = pps->trove;
+		generate_advice_feedback = pps->generate_advice_feedback;
+		generate_advice_string = pps->generate_advice_string;
+	}
+
+	/*
+	 * If we're trying to generate an advice string or if we're trying to
+	 * provide advice feedback, then we will need to create range table
+	 * identifiers.
+	 */
+	if (generate_advice_string || generate_advice_feedback)
+	{
+		pgpa_plan_walker(&walker, pstmt, pps->sj_unique_rels);
+		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
+	}
+
+	/* Generate the advice string, if we need to do so. */
+	if (generate_advice_string)
+	{
+		char	   *advice_string;
+		StringInfoData buf;
+
+		/* Generate a textual advice string. */
+		initStringInfo(&buf);
+		pgpa_output_advice(&buf, &walker, rt_identifiers);
+		advice_string = buf.data;
+
+		/* Save the advice string in the final plan. */
+		pgpa_items = lappend(pgpa_items,
+							 makeDefElem("advice_string",
+										 (Node *) makeString(advice_string),
+										 -1));
+	}
+
+	/*
+	 * If we're trying to provide advice feedback, then we will need to
+	 * analyze how successful the advice was.
+	 */
+	if (generate_advice_feedback)
+	{
+		List	   *feedback = NIL;
+
+		/*
+		 * Inject a Node-tree representation of all the trove-entry flags into
+		 * the PlannedStmt.
+		 */
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_SCAN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_JOIN,
+												rt_identifiers, &walker);
+		feedback = pgpa_planner_append_feedback(feedback,
+												trove,
+												PGPA_TROVE_LOOKUP_REL,
+												rt_identifiers, &walker);
+
+		pgpa_items = lappend(pgpa_items, makeDefElem("feedback",
+													 (Node *) feedback, -1));
+
+		/* If we were asked to generate feedback warnings, do so. */
+		if (pg_plan_advice_feedback_warnings)
+			pgpa_planner_feedback_warning(feedback);
+	}
+
+	/* Push whatever data we're saving into the PlannedStmt. */
+	if (pgpa_items != NIL)
+		pstmt->extension_state =
+			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);
+}
+
+/*
+ * 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;
+
+	/* 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. */
+	if (pps != NULL)
+		pgpa_ri_checker_save(pps, root, rel);
+
+	/* If query advice was provided, search for relevant entries. */
+	if (pps != NULL && pps->trove != NULL)
+	{
+		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,
+						  &tresult_scan);
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
+						  &tresult_rel);
+
+		/* If relevant entries were found, apply them. */
+		if (tresult_scan.indexes != NULL || tresult_rel.indexes != NULL)
+		{
+			uint64		original_mask = rel->pgs_mask;
+
+			pgpa_planner_apply_scan_advice(rel,
+										   tresult_scan.entries,
+										   tresult_scan.indexes,
+										   tresult_rel.entries,
+										   tresult_rel.indexes);
+
+			/* Emit debugging message, if enabled. */
+			if (pg_plan_advice_trace_mask && original_mask != rel->pgs_mask)
+			{
+				if (root->plan_name != NULL)
+					ereport(WARNING,
+							(errmsg("strategy mask for RTI %u in subplan \"%s\" changed from 0x%" PRIx64 " to 0x%" PRIx64,
+									rel->relid, root->plan_name,
+									original_mask, rel->pgs_mask)));
+				else
+					ereport(WARNING,
+							(errmsg("strategy mask for RTI %u changed from 0x%" PRIx64 " to 0x%" PRIx64,
+									rel->relid, original_mask,
+									rel->pgs_mask)));
+			}
+		}
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_build_simple_rel)
+		(*prev_build_simple_rel) (root, rel, rte);
+}
+
+/*
+ * Enforce any provided advice that is relevant to any method of implementing
+ * this join.
+ *
+ * Although we're passed the outerrel and innerrel here, those are just
+ * whatever values happened to prompt the creation of this joinrel; they
+ * shouldn't really influence our choice of what advice to apply.
+ */
+static void
+pgpa_joinrel_setup(PlannerInfo *root, RelOptInfo *joinrel,
+				   RelOptInfo *outerrel, RelOptInfo *innerrel,
+				   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+	{
+		uint64		original_mask = joinrel->pgs_mask;
+
+		pgpa_planner_apply_joinrel_advice(&joinrel->pgs_mask,
+										  root->plan_name,
+										  pjs);
+
+		/* Emit debugging message, if enabled. */
+		if (pg_plan_advice_trace_mask && original_mask != joinrel->pgs_mask)
+		{
+			if (root->plan_name != NULL)
+				ereport(WARNING,
+						(errmsg("strategy mask for join on RTIs %s in subplan \"%s\" changed from 0x%" PRIx64 " to 0x%" PRIx64,
+								pgpa_bms_to_cstring(joinrel->relids),
+								root->plan_name,
+								original_mask,
+								joinrel->pgs_mask)));
+			else
+				ereport(WARNING,
+						(errmsg("strategy mask for join on RTIs %s changed from 0x%" PRIx64 " to 0x%" PRIx64,
+								pgpa_bms_to_cstring(joinrel->relids),
+								original_mask,
+								joinrel->pgs_mask)));
+		}
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_joinrel_setup)
+		(*prev_joinrel_setup) (root, joinrel, outerrel, innerrel,
+							   sjinfo, restrictlist);
+}
+
+/*
+ * Enforce any provided advice that is relevant to this particular method of
+ * implementing this particular join.
+ */
+static void
+pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
+					 RelOptInfo *outerrel, RelOptInfo *innerrel,
+					 JoinType jointype, JoinPathExtraData *extra)
+{
+	pgpa_join_state *pjs;
+
+	Assert(bms_membership(joinrel->relids) == BMS_MULTIPLE);
+
+	/*
+	 * If we're considering implementing a semijoin by making one side unique,
+	 * make a note of it in the pgpa_planner_state. See comments for
+	 * pgpa_sj_unique_rel for why we do this.
+	 */
+	if (jointype == JOIN_UNIQUE_OUTER || jointype == JOIN_UNIQUE_INNER)
+	{
+		pgpa_planner_state *pps;
+		RelOptInfo *uniquerel;
+
+		uniquerel = jointype == JOIN_UNIQUE_OUTER ? outerrel : innerrel;
+		pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+		if (pps != NULL &&
+			(pps->generate_advice_string || pps->generate_advice_feedback))
+		{
+			bool		found = false;
+
+			/* Avoid adding duplicates. */
+			foreach_ptr(pgpa_sj_unique_rel, ur, pps->sj_unique_rels)
+			{
+				/*
+				 * We should always use the same pointer for the same plan
+				 * name, so we need not use strcmp() here.
+				 */
+				if (root->plan_name == ur->plan_name &&
+					bms_equal(uniquerel->relids, ur->relids))
+				{
+					found = true;
+					break;
+				}
+			}
+
+			/* If not a duplicate, append to the list. */
+			if (!found)
+			{
+				pgpa_sj_unique_rel *ur = palloc_object(pgpa_sj_unique_rel);
+
+				ur->plan_name = root->plan_name;
+				ur->relids = uniquerel->relids;
+				pps->sj_unique_rels = lappend(pps->sj_unique_rels, ur);
+			}
+		}
+	}
+
+	/* Get our private state information for this join. */
+	pjs = pgpa_get_join_state(root, joinrel, outerrel, innerrel);
+
+	/* If there is relevant advice, call a helper function to apply it. */
+	if (pjs != NULL)
+	{
+		uint64		original_mask = extra->pgs_mask;
+
+		pgpa_planner_apply_join_path_advice(jointype,
+											&extra->pgs_mask,
+											root->plan_name,
+											pjs);
+
+		/* Emit debugging message, if enabled. */
+		if (pg_plan_advice_trace_mask && original_mask != extra->pgs_mask)
+		{
+			if (root->plan_name != NULL)
+				ereport(WARNING,
+						(errmsg("strategy mask for %s join on %s with outer %s and inner %s in subplan \"%s\" changed from 0x%" PRIx64 " to 0x%" PRIx64,
+								pgpa_jointype_to_cstring(jointype),
+								pgpa_bms_to_cstring(joinrel->relids),
+								pgpa_bms_to_cstring(outerrel->relids),
+								pgpa_bms_to_cstring(innerrel->relids),
+								root->plan_name,
+								original_mask,
+								extra->pgs_mask)));
+			else
+				ereport(WARNING,
+						(errmsg("strategy mask for %s join on %s with outer %s and inner %s changed from 0x%" PRIx64 " to 0x%" PRIx64,
+								pgpa_jointype_to_cstring(jointype),
+								pgpa_bms_to_cstring(joinrel->relids),
+								pgpa_bms_to_cstring(outerrel->relids),
+								pgpa_bms_to_cstring(innerrel->relids),
+								original_mask,
+								extra->pgs_mask)));
+		}
+	}
+
+	/* Pass call to previous hook. */
+	if (prev_join_path_setup)
+		(*prev_join_path_setup) (root, joinrel, outerrel, innerrel,
+								 jointype, extra);
+}
+
+/*
+ * Search for advice pertaining to a proposed join.
+ */
+static pgpa_join_state *
+pgpa_get_join_state(PlannerInfo *root, RelOptInfo *joinrel,
+					RelOptInfo *outerrel, RelOptInfo *innerrel)
+{
+	pgpa_planner_state *pps;
+	pgpa_join_state *pjs;
+	bool		new_pjs = false;
+
+	/* Fetch our private state, set up by pgpa_planner_setup(). */
+	pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
+	if (pps == NULL || pps->trove == NULL)
+	{
+		/* No advice applies to this query, hence none to this joinrel. */
+		return NULL;
+	}
+
+	/*
+	 * See whether we've previously associated a pgpa_join_state with this
+	 * joinrel. If we have not, we need to try to construct one. If we have,
+	 * then there are two cases: (a) if innerrel and outerrel are unchanged,
+	 * we can simply use it, and (b) if they have changed, we need to rejigger
+	 * the array of identifiers but can still skip the trove lookup.
+	 */
+	pjs = GetRelOptInfoExtensionState(joinrel, planner_extension_id);
+	if (pjs != NULL)
+	{
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+		{
+			/*
+			 * If there's no potentially relevant advice, then the presence of
+			 * this pgpa_join_state acts like a negative cache entry: it tells
+			 * us not to bother searching the trove for advice, because we
+			 * will not find any.
+			 */
+			return NULL;
+		}
+
+		if (pjs->outerrel == outerrel && pjs->innerrel == innerrel)
+		{
+			/* No updates required, so just return. */
+			/* XXX. Does this need to do something different under GEQO? */
+			return pjs;
+		}
+	}
+
+	/*
+	 * If there's no pgpa_join_state yet, we need to allocate one. Trove keys
+	 * will not get built for RTE_JOIN RTEs, so the array may end up being
+	 * larger than needed. It's not worth trying to compute a perfectly
+	 * accurate count here.
+	 */
+	if (pjs == NULL)
+	{
+		int			pessimistic_count = bms_num_members(joinrel->relids);
+
+		pjs = palloc0_object(pgpa_join_state);
+		pjs->rids = palloc_array(pgpa_identifier, pessimistic_count);
+		new_pjs = true;
+	}
+
+	/*
+	 * Either we just allocated a new pgpa_join_state, or the existing one
+	 * needs reconfiguring for a new innerrel and outerrel. The required array
+	 * size can't change, so we can overwrite the existing one.
+	 */
+	pjs->outerrel = outerrel;
+	pjs->innerrel = innerrel;
+	pjs->outer_count =
+		pgpa_compute_identifiers_by_relids(root, outerrel->relids, pjs->rids);
+	pjs->inner_count =
+		pgpa_compute_identifiers_by_relids(root, innerrel->relids,
+										   pjs->rids + pjs->outer_count);
+
+	/*
+	 * If we allocated a new pgpa_join_state, search our trove of advice for
+	 * relevant entries. The trove lookup will return the same results for
+	 * every outerrel/innerrel combination, so we don't need to repeat that
+	 * work every time.
+	 */
+	if (new_pjs)
+	{
+		pgpa_trove_result tresult;
+
+		/* Find join entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_JOIN,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->join_entries = tresult.entries;
+		pjs->join_indexes = tresult.indexes;
+
+		/* Find rel entries. */
+		pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL,
+						  pjs->outer_count + pjs->inner_count,
+						  pjs->rids, &tresult);
+		pjs->rel_entries = tresult.entries;
+		pjs->rel_indexes = tresult.indexes;
+
+		/* Now that the new pgpa_join_state is fully valid, save a pointer. */
+		SetRelOptInfoExtensionState(joinrel, planner_extension_id, pjs);
+
+		/*
+		 * If there was no relevant advice found, just return NULL. This
+		 * pgpa_join_state will stick around as a sort of negative cache
+		 * entry, so that future calls for this same joinrel quickly return
+		 * NULL.
+		 */
+		if (pjs->join_indexes == NULL && pjs->rel_indexes == NULL)
+			return NULL;
+	}
+
+	return pjs;
+}
+
+/*
+ * Enforce overall restrictions on a join relation that apply uniformly
+ * regardless of the choice of inner and outer rel.
+ */
+static void
+pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
+								  pgpa_join_state *pjs)
+{
+	int			i = -1;
+	int			flags;
+	bool		gather_conflict = false;
+	uint64		gather_mask = 0;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	bool		partitionwise_conflict = false;
+	int			partitionwise_outcome = 0;
+	Bitmapset  *partitionwise_partial_match = NULL;
+	Bitmapset  *partitionwise_full_match = NULL;
+
+	/* Iterate over all possibly-relevant advice. */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type itm;
+		bool		full_match = false;
+		uint64		my_gather_mask = 0;
+		int			my_partitionwise_outcome = 0;	/* >0 yes, <0 no */
+
+		/*
+		 * For GATHER and GATHER_MERGE, if the specified relations exactly
+		 * match this joinrel, do whatever the advice says; otherwise, don't
+		 * allow Gather or Gather Merge at this level. For NO_GATHER, there
+		 * must be a single target relation which must be included in this
+		 * joinrel, so just don't allow Gather or Gather Merge here, full
+		 * stop.
+		 */
+		if (entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			full_match = true;
+		}
+		else
+		{
+			int			total_count;
+
+			total_count = pjs->outer_count + pjs->inner_count;
+			itm = pgpa_identifiers_match_target(total_count, pjs->rids,
+												entry->target);
+			Assert(itm != PGPA_ITM_DISJOINT);
+
+			if (itm == PGPA_ITM_EQUAL)
+			{
+				full_match = true;
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+					my_partitionwise_outcome = 1;
+				else if (entry->tag == PGPA_TAG_GATHER)
+					my_gather_mask = PGS_GATHER;
+				else if (entry->tag == PGPA_TAG_GATHER_MERGE)
+					my_gather_mask = PGS_GATHER_MERGE;
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+			else
+			{
+				/*
+				 * If specified relations don't exactly match this joinrel,
+				 * then we should do the opposite of whatever the advice says.
+				 * For instance, if we have PARTITIONWISE((a b c)) or
+				 * GATHER((a b c)) and this joinrel covers {a, b} or {a, b, c,
+				 * d} or {a, d}, we shouldn't plan it partitionwise or put a
+				 * Gather or Gather Merge on it here.
+				 *
+				 * Also, we can't put a Gather or Gather Merge at this level
+				 * if there is PARTITIONWISE advice that overlaps with it,
+				 * unless the PARTITIONWISE advice covers a subset of the
+				 * relations in the joinrel. To continue the previous example,
+				 * PARTITIONWISE((a b c)) is logically incompatible with
+				 * GATHER((a b)) or GATHER((a d)), but not with GATHER((a b c
+				 * d)).
+				 *
+				 * Conversely, we can't proceed partitionwise at this level if
+				 * there is overlapping GATHER or GATHER_MERGE advice, unless
+				 * that advice covers a superset of the relations in this
+				 * joinrel. This is just the flip side of the preceding point.
+				 */
+				if (entry->tag == PGPA_TAG_PARTITIONWISE)
+				{
+					my_partitionwise_outcome = -1;
+					if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+						my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+				}
+				else if (entry->tag == PGPA_TAG_GATHER ||
+						 entry->tag == PGPA_TAG_GATHER_MERGE)
+				{
+					my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+					if (itm != PGPA_ITM_KEYS_ARE_SUBSET)
+						my_partitionwise_outcome = -1;
+				}
+				else
+					elog(ERROR, "unexpected advice tag: %d",
+						 (int) entry->tag);
+			}
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (full_match)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+
+		/*
+		 * Likewise, if we set my_partitionwise_outcome up above, then we (1)
+		 * make a note if the advice conflicted, (2) remember what the desired
+		 * outcome was, and (3) remember whether this was a full or partial
+		 * match.
+		 */
+		if (my_partitionwise_outcome != 0)
+		{
+			if (partitionwise_outcome != 0 &&
+				partitionwise_outcome != my_partitionwise_outcome)
+				partitionwise_conflict = true;
+			partitionwise_outcome = my_partitionwise_outcome;
+			if (full_match)
+				partitionwise_full_match =
+					bms_add_member(partitionwise_full_match, i);
+			else
+				partitionwise_partial_match =
+					bms_add_member(partitionwise_partial_match, i);
+		}
+	}
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched, and if
+	 * the set of targets exactly matched this relation, fully matched. If
+	 * there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
+
+	/* Likewise for partitionwise advice. */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (partitionwise_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
+
+	/*
+	 * Enforce restrictions on the Gather/Gather Merge.  Only clear bits here,
+	 * so that we still respect the enable_* GUCs. Do nothing if the advice
+	 * conflicts.
+	 */
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		uint64		all_gather_mask;
+
+		all_gather_mask =
+			PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL;
+		*pgs_mask_p &= ~(all_gather_mask & ~gather_mask);
+	}
+
+	/*
+	 * As above, but for partitionwise advice.
+	 *
+	 * To induce a partitionwise join, we disable all the ordinary means of
+	 * performing a join, so that an Append or MergeAppend path will hopefully
+	 * be chosen.
+	 *
+	 * To prevent one, we just disable Append and MergeAppend.  Note that we
+	 * must not unset PGS_CONSIDER_PARTITIONWISE even when we don't want a
+	 * partitionwise join here, because we might want one at a higher level
+	 * that will construct its own paths using the ones from this level.
+	 */
+	if (partitionwise_outcome != 0 && !partitionwise_conflict)
+	{
+		if (partitionwise_outcome > 0)
+			*pgs_mask_p = (*pgs_mask_p & ~PGS_JOIN_ANY);
+		else
+			*pgs_mask_p &= ~(PGS_APPEND | PGS_MERGE_APPEND);
+	}
+}
+
+/*
+ * Enforce restrictions on the join order or join method.
+ */
+static void
+pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
+									char *plan_name,
+									pgpa_join_state *pjs)
+{
+	int			i = -1;
+	Bitmapset  *jo_permit_indexes = NULL;
+	Bitmapset  *jo_deny_indexes = NULL;
+	Bitmapset  *jo_deny_rel_indexes = NULL;
+	Bitmapset  *jm_indexes = NULL;
+	bool		jm_conflict = false;
+	uint32		join_mask = 0;
+	Bitmapset  *sj_permit_indexes = NULL;
+	Bitmapset  *sj_deny_indexes = NULL;
+
+	/*
+	 * Reconsider PARTITIONWISE(...) advice.
+	 *
+	 * We already thought about this for the joinrel as a whole, but in some
+	 * cases, partitionwise advice can also constrain the join order. For
+	 * instance, if the advice says PARTITIONWISE((t1 t2)), we shouldn't build
+	 * join paths for any joinrel that includes t1 or t2 unless it also
+	 * includes the other. In general, the partitionwise operation must have
+	 * already been completed within one side of the current join or the
+	 * other, else the join order is impermissible.
+	 *
+	 * NB: It might seem tempting to try to deal with PARTITIONWISE advice
+	 * entirely in this function, but that doesn't work. Here, we can only
+	 * affect the pgs_mask within a particular JoinPathExtraData, that is, for
+	 * a particular choice of innerrel and outerrel. Partitionwise paths are
+	 * not built that way, so we must set pgs_mask for the RelOptInfo, which
+	 * is best done in pgpa_planner_apply_joinrel_advice.
+	 */
+	while ((i = bms_next_member(pjs->rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->rel_entries[i];
+		pgpa_itm_type inner_itm;
+		pgpa_itm_type outer_itm;
+
+		if (entry->tag != PGPA_TAG_PARTITIONWISE)
+			continue;
+
+		outer_itm = pgpa_identifiers_match_target(pjs->outer_count,
+												  pjs->rids, entry->target);
+		if (outer_itm == PGPA_ITM_EQUAL ||
+			outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+			continue;
+
+		inner_itm = pgpa_identifiers_match_target(pjs->inner_count,
+												  pjs->rids + pjs->outer_count,
+												  entry->target);
+		if (inner_itm == PGPA_ITM_EQUAL ||
+			inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+			continue;
+
+		jo_deny_rel_indexes = bms_add_member(jo_deny_rel_indexes, i);
+	}
+
+	/* Iterate over advice that pertains to the join order and method. */
+	i = -1;
+	while ((i = bms_next_member(pjs->join_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &pjs->join_entries[i];
+		uint32		my_join_mask;
+
+		/* Handle join order advice. */
+		if (entry->tag == PGPA_TAG_JOIN_ORDER)
+		{
+			pgpa_jo_outcome jo_outcome;
+
+			jo_outcome = pgpa_join_order_permits_join(pjs->outer_count,
+													  pjs->inner_count,
+													  pjs->rids,
+													  entry);
+			if (jo_outcome == PGPA_JO_PERMITTED)
+				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+			else if (jo_outcome == PGPA_JO_DENIED)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			continue;
+		}
+
+		/* Handle join method advice. */
+		my_join_mask = pgpa_join_strategy_mask_from_advice_tag(entry->tag);
+		if (my_join_mask != 0)
+		{
+			bool		permit;
+			bool		restrict_method;
+
+			if (entry->tag == PGPA_TAG_FOREIGN_JOIN)
+				permit = pgpa_opaque_join_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			else
+				permit = pgpa_join_method_permits_join(pjs->outer_count,
+													   pjs->inner_count,
+													   pjs->rids,
+													   entry,
+													   &restrict_method);
+			if (!permit)
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				jm_indexes = bms_add_member(jm_indexes, i);
+				if (join_mask != 0 && join_mask != my_join_mask)
+					jm_conflict = true;
+				join_mask = my_join_mask;
+			}
+			continue;
+		}
+
+		/* Handle semijoin uniqueness advice. */
+		if (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE ||
+			entry->tag == PGPA_TAG_SEMIJOIN_NON_UNIQUE)
+		{
+			bool		outer_side_nullable;
+			bool		restrict_method;
+
+			/* Planner has nullable side of the semijoin on the outer side? */
+			outer_side_nullable = (jointype == JOIN_UNIQUE_OUTER ||
+								   jointype == JOIN_RIGHT_SEMI);
+
+			if (!pgpa_semijoin_permits_join(pjs->outer_count,
+											pjs->inner_count,
+											pjs->rids,
+											entry,
+											outer_side_nullable,
+											&restrict_method))
+				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
+			else if (restrict_method)
+			{
+				bool		advice_unique;
+				bool		jt_unique;
+				bool		jt_non_unique;
+
+				/* Advice wants to unique-ify and use a regular join? */
+				advice_unique = (entry->tag == PGPA_TAG_SEMIJOIN_UNIQUE);
+
+				/* Planner is trying to unique-ify and use a regular join? */
+				jt_unique = (jointype == JOIN_UNIQUE_INNER ||
+							 jointype == JOIN_UNIQUE_OUTER);
+
+				/* Planner is trying a semi-join, without unique-ifying? */
+				jt_non_unique = (jointype == JOIN_SEMI ||
+								 jointype == JOIN_RIGHT_SEMI);
+
+				if (!jt_unique && !jt_non_unique)
+				{
+					/*
+					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
+					 * or SJ_NON_UNIQUE can be applied.
+					 */
+					entry->flags |= PGPA_TE_INAPPLICABLE;
+				}
+				else if (advice_unique != jt_unique)
+					sj_deny_indexes = bms_add_member(sj_deny_indexes, i);
+				else
+					sj_permit_indexes = bms_add_member(sj_permit_indexes, i);
+			}
+			continue;
+		}
+	}
+
+	/*
+	 * If the advice indicates both that this join order is permissible and
+	 * also that it isn't, then mark advice related to the join order as
+	 * conflicting.
+	 */
+	if (jo_permit_indexes != NULL &&
+		(jo_deny_indexes != NULL || jo_deny_rel_indexes != NULL))
+	{
+		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->rel_entries, jo_deny_rel_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * If more than one join method specification is relevant here and they
+	 * differ, mark them all as conflicting.
+	 */
+	if (jm_conflict)
+		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
+							 PGPA_TE_CONFLICTING);
+
+	/* If semijoin advice says both yes and no, mark it all as conflicting. */
+	if (sj_permit_indexes != NULL && sj_deny_indexes != NULL)
+	{
+		pgpa_trove_set_flags(pjs->join_entries, sj_permit_indexes,
+							 PGPA_TE_CONFLICTING);
+		pgpa_trove_set_flags(pjs->join_entries, sj_deny_indexes,
+							 PGPA_TE_CONFLICTING);
+	}
+
+	/*
+	 * Enforce restrictions on the join order and join method, and any
+	 * semijoin-related restrictions. 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 ((jo_deny_indexes != NULL || jo_deny_rel_indexes != NULL) &&
+		jo_permit_indexes == NULL)
+		*pgs_mask_p &= ~PGS_JOIN_ANY;
+	if (join_mask != 0 && !jm_conflict)
+		*pgs_mask_p &= ~(PGS_JOIN_ANY & ~join_mask);
+	if (sj_deny_indexes != NULL && sj_permit_indexes == NULL)
+		*pgs_mask_p &= ~PGS_JOIN_ANY;
+}
+
+/*
+ * Translate an advice tag into a path generation strategy mask.
+ *
+ * This function can be called with tag types that don't represent join
+ * strategies. In such cases, we just return 0, which can't be confused with
+ * a valid mask.
+ */
+static uint64
+pgpa_join_strategy_mask_from_advice_tag(pgpa_advice_tag_type tag)
+{
+	switch (tag)
+	{
+		case PGPA_TAG_FOREIGN_JOIN:
+			return PGS_FOREIGNJOIN;
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return PGS_MERGEJOIN_PLAIN;
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return PGS_MERGEJOIN_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return PGS_NESTLOOP_PLAIN;
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return PGS_NESTLOOP_MATERIALIZE;
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return PGS_NESTLOOP_MEMOIZE;
+		case PGPA_TAG_HASH_JOIN:
+			return PGS_HASHJOIN;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Does a certain item of join order advice permit a certain join?
+ *
+ * Returns PGPA_JO_DENIED if the advice is incompatible with the proposed
+ * join order.
+ *
+ * Returns PGPA_JO_PERMITTED if the advice specifies exactly the proposed
+ * join order. This implies that a partitionwise join should not be
+ * performed at this level; rather, one of the traditional join methods
+ * should be used.
+ *
+ * Returns PGPA_JO_INDIFFERENT if the advice does not care what happens.
+ * We use this for unordered JOIN_ORDER sublists, which are compatible with
+ * partitionwise join but do not mandate it.
+ */
+static pgpa_jo_outcome
+pgpa_join_order_permits_join(int outer_count, int inner_count,
+							 pgpa_identifier *rids,
+							 pgpa_trove_entry *entry)
+{
+	bool		loop = true;
+	bool		sublist = false;
+	int			length;
+	int			outer_length;
+	pgpa_advice_target *target = entry->target;
+	pgpa_advice_target *prefix_target;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * Find the innermost sublist that contains all keys; if no sublist does,
+	 * then continue processing with the toplevel list.
+	 *
+	 * For example, if the advice says JOIN_ORDER(t1 t2 (t3 t4 t5)), then we
+	 * should evaluate joins that only involve t3, t4, and/or t5 against the
+	 * (t3 t4 t5) sublist, and others against the full list.
+	 *
+	 * Note that (1) outermost sublist is always ordered and (2) whenever we
+	 * zoom into an unordered sublist, we instantly return
+	 * PGPA_JO_INDIFFERENT.
+	 */
+	while (loop)
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+		loop = false;
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_itm_type itm;
+
+			if (child_target->ttype == PGPA_TARGET_IDENTIFIER)
+				continue;
+
+			itm = pgpa_identifiers_match_target(outer_count + inner_count,
+												rids, child_target);
+			if (itm == PGPA_ITM_EQUAL || itm == PGPA_ITM_KEYS_ARE_SUBSET)
+			{
+				if (child_target->ttype == PGPA_TARGET_ORDERED_LIST)
+				{
+					target = child_target;
+					sublist = true;
+					loop = true;
+					break;
+				}
+				else
+				{
+					Assert(child_target->ttype == PGPA_TARGET_UNORDERED_LIST);
+					return PGPA_JO_INDIFFERENT;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Try to find a prefix of the selected join order list that is exactly
+	 * equal to the outer side of the proposed join.
+	 */
+	length = list_length(target->children);
+	prefix_target = palloc0_object(pgpa_advice_target);
+	prefix_target->ttype = PGPA_TARGET_ORDERED_LIST;
+	for (outer_length = 1; outer_length <= length; ++outer_length)
+	{
+		pgpa_itm_type itm;
+
+		/* Avoid leaking memory in every loop iteration. */
+		if (prefix_target->children != NULL)
+			list_free(prefix_target->children);
+		prefix_target->children = list_copy_head(target->children,
+												 outer_length);
+
+		/* Search, hoping to find an exact match. */
+		itm = pgpa_identifiers_match_target(outer_count, rids, prefix_target);
+		if (itm == PGPA_ITM_EQUAL)
+			break;
+
+		/*
+		 * If the prefix of the join order list that we're considering
+		 * includes some but not all of the outer rels, we can make the prefix
+		 * longer to find an exact match. But if the advice hasn't mentioned
+		 * everything that's part of our outer rel yet, but has mentioned
+		 * things that are not, then this join doesn't match the join order
+		 * list.
+		 */
+		if (itm != PGPA_ITM_TARGETS_ARE_SUBSET)
+			return PGPA_JO_DENIED;
+	}
+
+	/*
+	 * If the previous loop stopped before the prefix_target included the
+	 * entire join order list, then the next member of the join order list
+	 * must exactly match the inner side of the join.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), if the outer side of the
+	 * current join includes only t1, then the inner side must be exactly t2;
+	 * if the outer side includes both t1 and t2, then the inner side must
+	 * include exactly t3, t4, and t5.
+	 */
+	if (outer_length < length)
+	{
+		pgpa_advice_target *inner_target;
+		pgpa_itm_type itm;
+
+		inner_target = list_nth(target->children, outer_length);
+
+		itm = pgpa_identifiers_match_target(inner_count, rids + outer_count,
+											inner_target);
+
+		/*
+		 * Before returning, consider whether we need to mark this entry as
+		 * fully matched. If we're considering the full list rather than a
+		 * sublist, and if we found every item but one on the outer side of
+		 * the join and the last item on the inner side of the join, then the
+		 * answer is yes.
+		 */
+		if (!sublist && outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
+			entry->flags |= PGPA_TE_MATCH_FULL;
+
+		return (itm == PGPA_ITM_EQUAL) ? PGPA_JO_PERMITTED : PGPA_JO_DENIED;
+	}
+
+	/*
+	 * If we get here, then the outer side of the join includes the entirety
+	 * of the join order list. In this case, we behave differently depending
+	 * on whether we're looking at the top-level join order list or sublist.
+	 * At the top-level, we treat the specified list as mandating that the
+	 * actual join order has the given list as a prefix, but a sublist
+	 * requires an exact match.
+	 *
+	 * Example: Given JOIN_ORDER(t1 t2 (t3 t4 t5)), we must start by joining
+	 * all five of those relations and in that sequence, but once that is
+	 * done, it's OK to join any other rels that are part of the join problem.
+	 * This allows a user to specify the driving table and perhaps the first
+	 * few things to which it should be joined while leaving the rest of the
+	 * join order up the optimizer. But it seems like it would be surprising,
+	 * given that specification, if the user could add t6 to the (t3 t4 t5)
+	 * sub-join, so we don't allow that. If we did want to allow it, the logic
+	 * earlier in this function would require substantial adjustment: we could
+	 * allow the t3-t4-t5-t6 join to be built here, but the next step of
+	 * joining t1-t2 to the result would still be rejected.
+	 */
+	if (!sublist)
+		entry->flags |= PGPA_TE_MATCH_FULL;
+	return sublist ? PGPA_JO_DENIED : PGPA_JO_PERMITTED;
+}
+
+/*
+ * Does a certain item of join method advice permit a certain join?
+ *
+ * Advice such as HASH_JOIN((x y)) means that there should be a hash join with
+ * exactly x and y on the inner side. Obviously, this means that if we are
+ * considering a join with exactly x and y on the inner side, we should enforce
+ * the use of a hash join. However, it also means that we must reject some
+ * incompatible join orders entirely.  For example, a join with exactly x
+ * and y on the outer side shouldn't be allowed, because such paths might win
+ * over the advice-driven path on cost.
+ *
+ * To accommodate these requirements, this function returns true if the join
+ * should be allowed and false if it should not. Furthermore, *restrict_method
+ * is set to true if the join method should be enforced and false if not.
+ */
+static bool
+pgpa_join_method_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	/*
+	 * If our inner rel mentions exactly the same relations as the advice
+	 * target, allow the join and enforce the join method restriction.
+	 *
+	 * If our inner rel mentions a superset of the target relations, allow the
+	 * join. The join we care about has already taken place, and this advice
+	 * imposes no further restrictions.
+	 */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If our outer rel mentions a superset of the relations in the advice
+	 * target, no restrictions apply, because the join we care about has
+	 * already taken place.
+	 *
+	 * On the other hand, if our outer rel mentions exactly the relations
+	 * mentioned in the advice target, the planner is trying to reverse the
+	 * sides of the join as compared with our desired outcome. Reject that.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+	else if (outer_itm == PGPA_ITM_EQUAL)
+		return false;
+
+	/*
+	 * If the advice target mentions only a single relation, the test below
+	 * cannot ever pass, so save some work by exiting now.
+	 */
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+		return false;
+
+	/*
+	 * If everything in the joinrel appears in the advice target, we're below
+	 * the level of the join we want to control.
+	 *
+	 * For example, HASH_JOIN((x y)) doesn't restrict how x and y can be
+	 * joined.
+	 *
+	 * This lookup shouldn't return PGPA_ITM_DISJOINT, because any such advice
+	 * should not have been returned from the trove in the first place.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've already permitted all allowable cases, so reject this.
+	 *
+	 * If we reach this point, then the advice overlaps with this join but
+	 * isn't entirely contained within either side, and there's also at least
+	 * one relation present in the join that isn't mentioned by the advice.
+	 *
+	 * For instance, in the HASH_JOIN((x y)) example, we would reach here if x
+	 * were on one side of the join, y on the other, and at least one of the
+	 * two sides also included some other relation, say t. In that case,
+	 * accepting this join would allow the (x y t) joinrel to contain
+	 * non-disabled paths that do not put (x y) on the inner side of a hash
+	 * join; we could instead end up with something like (x JOIN t) JOIN y.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning an opaque join permit a certain join?
+ *
+ * By an opaque join, we mean one where the exact mechanism by which the
+ * join is performed is not visible to PostgreSQL. Currently this is the
+ * case only for foreign joins: FOREIGN_JOIN((x y z)) means that x, y, and
+ * z are joined on the remote side, but we know nothing about the join order
+ * or join methods used over there.
+ *
+ * The logic here needs to differ from pgpa_join_method_permits_join because,
+ * for other join types, the advice target is the set of inner rels; here, it
+ * includes both inner and outer rels.
+ */
+static bool
+pgpa_opaque_join_permits_join(int outer_count, int inner_count,
+							  pgpa_identifier *rids,
+							  pgpa_trove_entry *entry,
+							  bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	*restrict_method = false;
+
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	if (join_itm == PGPA_ITM_EQUAL)
+	{
+		/*
+		 * We have an exact match, and should therefore allow the join and
+		 * enforce the use of the relevant opaque join method.
+		 */
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		*restrict_method = true;
+		return true;
+	}
+
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+	{
+		/*
+		 * If join_itm == PGPA_ITM_TARGETS_ARE_SUBSET, then the join we care
+		 * about has already taken place and no further restrictions apply.
+		 *
+		 * If join_itm == PGPA_ITM_KEYS_ARE_SUBSET, we're still building up to
+		 * the join we care about and have not introduced any extraneous
+		 * relations not named in the advice. Note that ForeignScan paths for
+		 * joins are built up from ForeignScan paths from underlying joins and
+		 * scans, so we must not disable this join when considering a subset
+		 * of the relations we ultimately want.
+		 */
+		return true;
+	}
+
+	/*
+	 * The advice overlaps the join, but at least one relation is present in
+	 * the join that isn't mentioned by the advice. We want to disable such
+	 * paths so that we actually push down the join as intended.
+	 */
+	return false;
+}
+
+/*
+ * Does advice concerning a semijoin permit a certain join?
+ *
+ * Unlike join method advice, which lists the rels on the inner side of the
+ * join, semijoin uniqueness advice lists the rels on the nullable side of the
+ * join. Those can be the same, if the join type is JOIN_UNIQUE_INNER or
+ * JOIN_SEMI, or they can be different, in case of JOIN_UNIQUE_OUTER or
+ * JOIN_RIGHT_SEMI.
+ *
+ * We don't know here whether the caller specified SEMIJOIN_UNIQUE or
+ * SEMIJOIN_NON_UNIQUE. The caller should check the join type against the
+ * advice type if and only if we set *restrict_method to true.
+ */
+static bool
+pgpa_semijoin_permits_join(int outer_count, int inner_count,
+						   pgpa_identifier *rids,
+						   pgpa_trove_entry *entry,
+						   bool outer_is_nullable,
+						   bool *restrict_method)
+{
+	pgpa_advice_target *target = entry->target;
+	pgpa_itm_type join_itm;
+	pgpa_itm_type inner_itm;
+	pgpa_itm_type outer_itm;
+
+	*restrict_method = false;
+
+	/* We definitely have at least a partial match for this trove entry. */
+	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+
+	/*
+	 * If outer rel is the nullable side and contains exactly the same
+	 * relations as the advice target, then the join order is allowable, but
+	 * the caller must check whether the advice tag (either SEMIJOIN_UNIQUE or
+	 * SEMIJOIN_NON_UNIQUE) matches the join type.
+	 *
+	 * If the outer rel is a superset of the target relations, the join we
+	 * care about has already taken place, so we should impose no further
+	 * restrictions.
+	 */
+	outer_itm = pgpa_identifiers_match_target(outer_count,
+											  rids, target);
+	if (outer_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		if (outer_is_nullable)
+		{
+			*restrict_method = true;
+			return true;
+		}
+	}
+	else if (outer_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/* As above, but for the inner rel. */
+	inner_itm = pgpa_identifiers_match_target(inner_count,
+											  rids + outer_count,
+											  target);
+	if (inner_itm == PGPA_ITM_EQUAL)
+	{
+		entry->flags |= PGPA_TE_MATCH_FULL;
+		if (!outer_is_nullable)
+		{
+			*restrict_method = true;
+			return true;
+		}
+	}
+	else if (inner_itm == PGPA_ITM_TARGETS_ARE_SUBSET)
+		return true;
+
+	/*
+	 * If everything in the joinrel appears in the advice target, we're below
+	 * the level of the join we want to control.
+	 */
+	join_itm = pgpa_identifiers_match_target(outer_count + inner_count,
+											 rids, target);
+	Assert(join_itm != PGPA_ITM_DISJOINT);
+	if (join_itm == PGPA_ITM_KEYS_ARE_SUBSET ||
+		join_itm == PGPA_ITM_EQUAL)
+		return true;
+
+	/*
+	 * We've tested for all allowable possibilities, and so must reject this
+	 * join order. This can happen in two ways.
+	 *
+	 * First, we might be considering a semijoin that overlaps incompletely
+	 * with one or both sides of the join. For example, if the user has
+	 * specified SEMIJOIN_UNIQUE((t1 t2)) or SEMIJOIN_NON_UNIQUE((t1 t2)), we
+	 * should reject a proposed t2-t3 join, since that could not result in a
+	 * final plan compatible with the advice.
+	 *
+	 * Second, we might be considering a semijoin where the advice target
+	 * perfectly matches one side of the join, but it's the wrong one. For
+	 * example, in the example above, we might see a 3-way join between t1,
+	 * t2, and t3, with (t1 t2) on the non-nullable side. That, too, would be
+	 * incompatible with the advice.
+	 */
+	return false;
+}
+
+/*
+ * Apply scan advice to a RelOptInfo.
+ */
+static void
+pgpa_planner_apply_scan_advice(RelOptInfo *rel,
+							   pgpa_trove_entry *scan_entries,
+							   Bitmapset *scan_indexes,
+							   pgpa_trove_entry *rel_entries,
+							   Bitmapset *rel_indexes)
+{
+	bool		gather_conflict = false;
+	Bitmapset  *gather_partial_match = NULL;
+	Bitmapset  *gather_full_match = NULL;
+	int			i = -1;
+	pgpa_trove_entry *scan_entry = NULL;
+	int			flags;
+	bool		scan_type_conflict = false;
+	Bitmapset  *scan_type_indexes = NULL;
+	Bitmapset  *scan_type_rel_indexes = NULL;
+	uint64		gather_mask = 0;
+	uint64		scan_type = 0;
+
+	/* 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;
+
+		/* Translate our advice tags to a scan strategy advice value. */
+		if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
+		{
+			/*
+			 * Currently, PGS_CONSIDER_INDEXONLY can suppress Bitmap Heap
+			 * Scans, so don't clear it when such a scan is requested. This
+			 * happens because build_index_scan() thinks that the possibility
+			 * of an index-only scan is a sufficient reason to consider using
+			 * an otherwise-useless index, and get_index_paths() thinks that
+			 * the same paths that are useful for index or index-only scans
+			 * should also be considered for bitmap scans. Perhaps that logic
+			 * should be tightened up, but until then we need to include
+			 * PGS_CONSIDER_INDEXONLY in my_scan_type here.
+			 */
+			my_scan_type = PGS_BITMAPSCAN | PGS_CONSIDER_INDEXONLY;
+		}
+		else if (my_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN)
+			my_scan_type = PGS_INDEXONLYSCAN | PGS_CONSIDER_INDEXONLY;
+		else if (my_entry->tag == PGPA_TAG_INDEX_SCAN)
+			my_scan_type = PGS_INDEXSCAN;
+		else if (my_entry->tag == PGPA_TAG_SEQ_SCAN)
+			my_scan_type = PGS_SEQSCAN;
+		else if (my_entry->tag == PGPA_TAG_TID_SCAN)
+			my_scan_type = PGS_TIDSCAN;
+
+		/*
+		 * If this is understandable scan advice, hang on to the entry, the
+		 * inferred scan type, and the index at which we found it.
+		 *
+		 * Also make a note if we see conflicting scan type advice. Note that
+		 * we regard two index specifications as conflicting unless they match
+		 * exactly. In theory, perhaps we could regard INDEX_SCAN(a c) and
+		 * 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 (scan_type != 0 && scan_type != my_scan_type)
+				scan_type_conflict = true;
+			if (!scan_type_conflict && scan_entry != NULL &&
+				my_entry->target->itarget != NULL &&
+				scan_entry->target->itarget != NULL &&
+				!pgpa_index_targets_equal(scan_entry->target->itarget,
+										  my_entry->target->itarget))
+				scan_type_conflict = true;
+			scan_entry = my_entry;
+			scan_type = my_scan_type;
+			scan_type_indexes = bms_add_member(scan_type_indexes, i);
+		}
+	}
+
+	/* Scrutinize available gather-related and partitionwise advice. */
+	i = -1;
+	while ((i = bms_next_member(rel_indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *my_entry = &rel_entries[i];
+		uint64		my_gather_mask = 0;
+		bool		just_one_rel;
+
+		just_one_rel = my_entry->target->ttype == PGPA_TARGET_IDENTIFIER
+			|| list_length(my_entry->target->children) == 1;
+
+		/*
+		 * PARTITIONWISE behaves like a scan type, except that if there's more
+		 * than one relation targeted, it has no effect at this level.
+		 */
+		if (my_entry->tag == PGPA_TAG_PARTITIONWISE)
+		{
+			if (just_one_rel)
+			{
+				const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
+
+				if (scan_type != 0 && scan_type != my_scan_type)
+					scan_type_conflict = true;
+				scan_entry = my_entry;
+				scan_type = my_scan_type;
+				scan_type_rel_indexes =
+					bms_add_member(scan_type_rel_indexes, i);
+			}
+			continue;
+		}
+
+		/*
+		 * GATHER and GATHER_MERGE applied to a single rel mean that we should
+		 * use the corresponding strategy here, while applying either to more
+		 * than one rel means we should not use those strategies here, but
+		 * rather at the level of the joinrel that corresponds to what was
+		 * specified. NO_GATHER can only be applied to single rels.
+		 *
+		 * Note that setting PGS_CONSIDER_NONPARTIAL in my_gather_mask is
+		 * equivalent to allowing the non-use of either form of Gather here.
+		 */
+		if (my_entry->tag == PGPA_TAG_GATHER ||
+			my_entry->tag == PGPA_TAG_GATHER_MERGE)
+		{
+			if (!just_one_rel)
+				my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+			else if (my_entry->tag == PGPA_TAG_GATHER)
+				my_gather_mask = PGS_GATHER;
+			else
+				my_gather_mask = PGS_GATHER_MERGE;
+		}
+		else if (my_entry->tag == PGPA_TAG_NO_GATHER)
+		{
+			Assert(just_one_rel);
+			my_gather_mask = PGS_CONSIDER_NONPARTIAL;
+		}
+
+		/*
+		 * If we set my_gather_mask up above, then we (1) make a note if the
+		 * advice conflicted, (2) remember the mask value, and (3) remember
+		 * whether this was a full or partial match.
+		 */
+		if (my_gather_mask != 0)
+		{
+			if (gather_mask != 0 && gather_mask != my_gather_mask)
+				gather_conflict = true;
+			gather_mask = my_gather_mask;
+			if (just_one_rel)
+				gather_full_match = bms_add_member(gather_full_match, i);
+			else
+				gather_partial_match = bms_add_member(gather_partial_match, i);
+		}
+	}
+
+	/* Enforce choice of index. */
+	if (scan_entry != NULL && !scan_type_conflict &&
+		(scan_entry->tag == PGPA_TAG_INDEX_SCAN ||
+		 scan_entry->tag == PGPA_TAG_INDEX_ONLY_SCAN))
+	{
+		pgpa_index_target *itarget = scan_entry->target->itarget;
+		IndexOptInfo *matched_index = NULL;
+
+		foreach_node(IndexOptInfo, index, rel->indexlist)
+		{
+			char	   *relname = get_rel_name(index->indexoid);
+			Oid			nspoid = get_rel_namespace(index->indexoid);
+			char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+
+			if (strcmp(itarget->indname, relname) == 0 &&
+				(itarget->indnamespace == NULL ||
+				 strcmp(itarget->indnamespace, relnamespace) == 0))
+			{
+				matched_index = index;
+				break;
+			}
+		}
+
+		if (matched_index == NULL)
+		{
+			/* Don't force the scan type if the index doesn't exist. */
+			scan_type = 0;
+
+			/* Mark advice as inapplicable. */
+			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
+								 PGPA_TE_INAPPLICABLE);
+		}
+		else
+		{
+			/* Disable every other index. */
+			foreach_node(IndexOptInfo, index, rel->indexlist)
+			{
+				if (index != matched_index)
+					index->disabled = true;
+			}
+		}
+	}
+
+	/*
+	 * Mark all the scan method entries as fully matched; and if they specify
+	 * different things, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	if (scan_type_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
+	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
+
+	/*
+	 * Mark every Gather-related piece of advice as partially matched. Mark
+	 * the ones that included this relation as a target by itself as fully
+	 * matched. If there was a conflict, mark them all as conflicting.
+	 */
+	flags = PGPA_TE_MATCH_PARTIAL;
+	if (gather_conflict)
+		flags |= PGPA_TE_CONFLICTING;
+	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
+	flags |= PGPA_TE_MATCH_FULL;
+	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
+
+	/*
+	 * Enforce restrictions on the scan type and use of Gather/Gather Merge.
+	 * 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;
+		rel->pgs_mask &= ~(all_scan_mask & ~scan_type);
+	}
+	if (gather_mask != 0 && !gather_conflict)
+	{
+		uint64		all_gather_mask;
+
+		all_gather_mask =
+			PGS_GATHER | PGS_GATHER_MERGE | PGS_CONSIDER_NONPARTIAL;
+		rel->pgs_mask &= ~(all_gather_mask & ~gather_mask);
+	}
+}
+
+/*
+ * Add feedback entries for one trove slice to the provided list and
+ * return the resulting list.
+ *
+ * Feedback entries are generated from the trove entry's flags. It's assumed
+ * that the caller has already set all relevant flags with the exception of
+ * PGPA_TE_FAILED. We set that flag here if appropriate.
+ */
+static List *
+pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
+							 pgpa_trove_lookup_type type,
+							 pgpa_identifier *rt_identifiers,
+							 pgpa_plan_walker_context *walker)
+{
+	pgpa_trove_entry *entries;
+	int			nentries;
+
+	pgpa_trove_lookup_all(trove, type, &entries, &nentries);
+	for (int i = 0; i < nentries; ++i)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+		DefElem    *item;
+
+		/*
+		 * If this entry was fully matched, check whether generating advice
+		 * from this plan would produce such an entry. If not, label the entry
+		 * as failed.
+		 */
+		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+			!pgpa_walker_would_advise(walker, rt_identifiers,
+									  entry->tag, entry->target))
+			entry->flags |= PGPA_TE_FAILED;
+
+		item = makeDefElem(pgpa_cstring_trove_entry(entry),
+						   (Node *) makeInteger(entry->flags), -1);
+		list = lappend(list, item);
+	}
+
+	return list;
+}
+
+/*
+ * Emit a WARNING to tell the user about a problem with the supplied plan
+ * advice.
+ */
+static void
+pgpa_planner_feedback_warning(List *feedback)
+{
+	StringInfoData detailbuf;
+	StringInfoData flagbuf;
+
+	/* Quick exit if there's no feedback. */
+	if (feedback == NIL)
+		return;
+
+	/* Initialize buffers. */
+	initStringInfo(&detailbuf);
+	initStringInfo(&flagbuf);
+
+	/* Main loop. */
+	foreach_node(DefElem, item, feedback)
+	{
+		int			flags = defGetInt32(item);
+
+		/*
+		 * Don't emit anything if it was fully matched with no problems found.
+		 *
+		 * NB: Feedback should never be marked fully matched without also
+		 * being marked partially matched.
+		 */
+		if (flags == (PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL))
+			continue;
+
+		/*
+		 * Terminate each detail line except the last with a newline. This is
+		 * also a convenient place to reset flagbuf.
+		 */
+		if (detailbuf.len > 0)
+		{
+			appendStringInfoChar(&detailbuf, '\n');
+			resetStringInfo(&flagbuf);
+		}
+
+		/* Generate output. */
+		pgpa_trove_append_flags(&flagbuf, flags);
+		appendStringInfo(&detailbuf, "advice %s feedback is \"%s\"",
+						 item->defname, flagbuf.data);
+	}
+
+	/* Emit the warning, if any problems were found. */
+	if (detailbuf.len > 0)
+		ereport(WARNING,
+				errmsg("supplied plan advice was not enforced"),
+				errdetail("%s", detailbuf.data));
+}
+
+#ifdef USE_ASSERT_CHECKING
+
+/*
+ * Fast hash function for a key consisting of an RTI and plan name.
+ */
+static uint32
+pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	hs.accum = key.rti;
+	fasthash_combine(&hs);
+
+	/* plan_name can be NULL */
+	if (key.plan_name == NULL)
+		sp_len = 0;
+	else
+		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+
+	/* hashfn_unstable.h recommends using string length as tweak */
+	return fasthash_final32(&hs, sp_len);
+}
+
+#endif
+
+/*
+ * Save the range table identifier for one relation for future cross-checking.
+ */
+static void
+pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
+					 RelOptInfo *rel)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_ri_checker_key key;
+	pgpa_ri_checker *check;
+	pgpa_identifier rid;
+	const char *rid_string;
+	bool		found;
+
+	key.rti = bms_singleton_member(rel->relids);
+	key.plan_name = root->plan_name;
+	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
+	rid_string = pgpa_identifier_string(&rid);
+	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
+	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
+	check->rid_string = rid_string;
+#endif
+}
+
+/*
+ * 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_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
+{
+#ifdef USE_ASSERT_CHECKING
+	pgpa_identifier *rt_identifiers;
+	pgpa_ri_check_iterator it;
+	pgpa_ri_checker *check;
+
+	/* 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. */
+	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
+	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	{
+		int			rtoffset = 0;
+		const char *rid_string;
+		Index		flat_rti;
+
+		/*
+		 * 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.
+		 */
+		if (check->key.plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				/*
+				 * If rtinfo->dummy is set, then the subquery's range table
+				 * will only have been partially copied to the final range
+				 * table. Specifically, only RTE_RELATION entries and
+				 * 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
+				 * in the final rtable.
+				 *
+				 * With more complex logic, we could work around that problem
+				 * by remembering the whole contents of the subquery's rtable
+				 * during planning, determining which of those would have been
+				 * copied to the final rtable, and matching them up. But it
+				 * doesn't seem like a worthwhile endeavor for right now,
+				 * because RTIs from such subqueries won't appear in the plan
+				 * tree itself, just in the range table. Hence, we can neither
+				 * generate nor accept advice for them.
+				 */
+				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+					&& !rtinfo->dummy)
+				{
+					rtoffset = rtinfo->rtoffset;
+					Assert(rtoffset > 0);
+					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;
+		}
+
+		/*
+		 * check->key.rti is the RTI that we saw prior to range-table
+		 * flattening, so we must add the appropriate RT offset to get the
+		 * final RTI.
+		 */
+		flat_rti = check->key.rti + rtoffset;
+		Assert(flat_rti <= list_length(pstmt->rtable));
+
+		/* Assert that the string we compute now matches the previous one. */
+		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
+		Assert(strcmp(rid_string, check->rid_string) == 0);
+	}
+#endif
+}
+
+/*
+ * Convert a bitmapset to a C string of comma-separated integers.
+ */
+static char *
+pgpa_bms_to_cstring(Bitmapset *bms)
+{
+	StringInfoData buf;
+	int			x = -1;
+
+	if (bms_is_empty(bms))
+		return "none";
+
+	initStringInfo(&buf);
+	while ((x = bms_next_member(bms, x)) >= 0)
+	{
+		if (buf.len > 0)
+			appendStringInfo(&buf, ", %d", x);
+		else
+			appendStringInfo(&buf, "%d", x);
+	}
+
+	return buf.data;
+}
+
+/*
+ * Convert a JoinType to a C string.
+ */
+static const char *
+pgpa_jointype_to_cstring(JoinType jointype)
+{
+	switch (jointype)
+	{
+		case JOIN_INNER:
+			return "inner";
+		case JOIN_LEFT:
+			return "left";
+		case JOIN_FULL:
+			return "full";
+		case JOIN_RIGHT:
+			return "right";
+		case JOIN_SEMI:
+			return "semi";
+		case JOIN_ANTI:
+			return "anti";
+		case JOIN_RIGHT_SEMI:
+			return "right semi";
+		case JOIN_RIGHT_ANTI:
+			return "right anti";
+		case JOIN_UNIQUE_OUTER:
+			return "unique outer";
+		case JOIN_UNIQUE_INNER:
+			return "unique inner";
+	}
+	return "???";
+}
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
new file mode 100644
index 00000000000..c70e486a7f3
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_planner.h
+ *	  planner integration for pg_plan_advice
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_planner.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_PLANNER_H
+#define PGPA_PLANNER_H
+
+extern void pgpa_planner_install_hooks(void);
+
+extern int	pgpa_planner_generate_advice;
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
new file mode 100644
index 00000000000..14bde3e149a
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -0,0 +1,271 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.c
+ *	  analysis of scans in Plan trees
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+
+static pgpa_scan *pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								 pgpa_scan_strategy strategy,
+								 Bitmapset *relids);
+
+
+static RTEKind unique_nonjoin_rtekind(Bitmapset *relids, List *rtable);
+
+/*
+ * Build a pgpa_scan object for a Plan node and update the plan walker
+ * context as appropriate.  If this is an Append or MergeAppend scan, also
+ * build pgpa_scan for any scans that were consolidated into this one by
+ * Append/MergeAppend pull-up.
+ *
+ * If there is at least one ElidedNode for this plan node, pass the uppermost
+ * one as elided_node, else pass NULL.
+ *
+ * Set the 'beneath_any_gather' node if we are underneath a Gather or
+ * Gather Merge node (except for a single-copy Gather node, for which
+ * GATHER or GATHER_MERGE advice should not be emitted).
+ *
+ * Set the 'within_join_problem' flag if we're inside of a join problem and
+ * not otherwise.
+ */
+pgpa_scan *
+pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+				ElidedNode *elided_node,
+				bool beneath_any_gather, bool within_join_problem)
+{
+	pgpa_scan_strategy strategy = PGPA_SCAN_ORDINARY;
+	Bitmapset  *relids = NULL;
+	int			rti = -1;
+	List	   *child_append_relid_sets = NIL;
+	NodeTag		nodetype = nodeTag(plan);
+
+	if (elided_node != NULL)
+	{
+		nodetype = elided_node->elided_type;
+		relids = elided_node->relids;
+
+		/*
+		 * If setrefs processing elided an Append or MergeAppend node that had
+		 * only one surviving child, it could be either a partitionwise
+		 * operation or a setop over subqueries, depending on the rtekind.
+		 *
+		 * A setop over subqueries, or a trivial SubqueryScan that was elided,
+		 * is an "ordinary" scan i.e. one for which we do not need to generate
+		 * advice because the planner has not made any meaningful choice.
+		 *
+		 * Note that the PGPA_SCAN_PARTITIONWISE case also includes
+		 * partitionwise joins; this module considers those to be a form of
+		 * scan, since they lack internal structure that we can decompose.
+		 */
+		if ((nodetype == T_Append || nodetype == T_MergeAppend) &&
+			unique_nonjoin_rtekind(relids,
+								   walker->pstmt->rtable) == RTE_RELATION)
+			strategy = PGPA_SCAN_PARTITIONWISE;
+		else
+			strategy = PGPA_SCAN_ORDINARY;
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+	{
+		relids = bms_make_singleton(rti);
+
+		switch (nodeTag(plan))
+		{
+			case T_SeqScan:
+				strategy = PGPA_SCAN_SEQ;
+				break;
+			case T_BitmapHeapScan:
+				strategy = PGPA_SCAN_BITMAP_HEAP;
+				break;
+			case T_IndexScan:
+				strategy = PGPA_SCAN_INDEX;
+				break;
+			case T_IndexOnlyScan:
+				strategy = PGPA_SCAN_INDEX_ONLY;
+				break;
+			case T_TidScan:
+			case T_TidRangeScan:
+				strategy = PGPA_SCAN_TID;
+				break;
+			default:
+
+				/*
+				 * This case includes a ForeignScan targeting a single
+				 * relation; no other strategy is possible in that case, but
+				 * see below, where things are different in multi-relation
+				 * cases.
+				 */
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+	}
+	else if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		switch (nodeTag(plan))
+		{
+			case T_ForeignScan:
+
+				/*
+				 * If multiple relations are being targeted by a single
+				 * foreign scan, then the foreign join has been pushed to the
+				 * remote side, and we want that to be reflected in the
+				 * generated advice.
+				 */
+				strategy = PGPA_SCAN_FOREIGN;
+				break;
+			case T_Append:
+
+				/*
+				 * Append nodes can represent partitionwise scans of a
+				 * relation, but when they implement a set operation, they are
+				 * just ordinary scans.
+				 */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+
+				/* Be sure to account for pulled-up scans. */
+				child_append_relid_sets =
+					((Append *) plan)->child_append_relid_sets;
+				break;
+			case T_MergeAppend:
+				/* Same logic here as for Append, above. */
+				if (unique_nonjoin_rtekind(relids, walker->pstmt->rtable)
+					== RTE_RELATION)
+					strategy = PGPA_SCAN_PARTITIONWISE;
+				else
+					strategy = PGPA_SCAN_ORDINARY;
+
+				/* Be sure to account for pulled-up scans. */
+				child_append_relid_sets =
+					((MergeAppend *) plan)->child_append_relid_sets;
+				break;
+			default:
+				strategy = PGPA_SCAN_ORDINARY;
+				break;
+		}
+
+
+		/* Join RTIs can be present, but advice never refers to them. */
+		relids = pgpa_filter_out_join_relids(relids, walker->pstmt->rtable);
+	}
+
+	/*
+	 * If this is an Append or MergeAppend node into which subordinate Append
+	 * or MergeAppend paths were merged, each of those merged paths is
+	 * effectively another scan for which we need to account.
+	 */
+	foreach_node(Bitmapset, child_relids, child_append_relid_sets)
+	{
+		Bitmapset  *child_nonjoin_relids;
+
+		child_nonjoin_relids =
+			pgpa_filter_out_join_relids(child_relids,
+										walker->pstmt->rtable);
+		(void) pgpa_make_scan(walker, plan, strategy,
+							  child_nonjoin_relids);
+	}
+
+	/*
+	 * If this plan node has no associated RTIs, it's not a scan. When the
+	 * 'within_join_problem' flag is set, that's unexpected, so throw an
+	 * error, else return quietly.
+	 */
+	if (relids == NULL)
+	{
+		if (within_join_problem)
+			elog(ERROR, "plan node has no RTIs: %d", (int) nodeTag(plan));
+		return NULL;
+	}
+
+	/*
+	 * Add the appropriate set of RTIs to walker->no_gather_scans.
+	 *
+	 * Add nothing if we're beneath a Gather or Gather Merge node, since
+	 * NO_GATHER advice is clearly inappropriate in that situation.
+	 *
+	 * Add nothing if this is an Append or MergeAppend node, whether or not
+	 * elided. We'll emit NO_GATHER() for the underlying scan, which is good
+	 * enough.
+	 */
+	if (!beneath_any_gather && nodetype != T_Append &&
+		nodetype != T_MergeAppend)
+		walker->no_gather_scans =
+			bms_add_members(walker->no_gather_scans, relids);
+
+	/* Caller tells us whether NO_GATHER() advice for this scan is needed. */
+	return pgpa_make_scan(walker, plan, strategy, relids);
+}
+
+/*
+ * Create a single pgpa_scan object and update the pgpa_plan_walker_context.
+ */
+static pgpa_scan *
+pgpa_make_scan(pgpa_plan_walker_context *walker, Plan *plan,
+			   pgpa_scan_strategy strategy, Bitmapset *relids)
+{
+	pgpa_scan  *scan;
+
+	/* Create the scan object. */
+	scan = palloc(sizeof(pgpa_scan));
+	scan->plan = plan;
+	scan->strategy = strategy;
+	scan->relids = relids;
+
+	/* Add it to the appropriate list. */
+	walker->scans[scan->strategy] = lappend(walker->scans[scan->strategy],
+											scan);
+
+	return scan;
+}
+
+/*
+ * Determine the unique rtekind of a set of relids.
+ */
+static RTEKind
+unique_nonjoin_rtekind(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	bool		first = true;
+	RTEKind		rtekind;
+
+	Assert(relids != NULL);
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		if (first)
+		{
+			rtekind = rte->rtekind;
+			first = false;
+		}
+		else if (rtekind != rte->rtekind)
+			elog(ERROR, "rtekind mismatch: %d vs. %d",
+				 rtekind, rte->rtekind);
+	}
+
+	if (first)
+		elog(ERROR, "no non-RTE_JOIN RTEs found");
+
+	return rtekind;
+}
diff --git a/contrib/pg_plan_advice/pgpa_scan.h b/contrib/pg_plan_advice/pgpa_scan.h
new file mode 100644
index 00000000000..391c1a4b596
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scan.h
@@ -0,0 +1,85 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_scan.h
+ *	  analysis of scans in Plan trees
+ *
+ * For purposes of this module, a "scan" includes (1) single plan nodes that
+ * scan multiple RTIs, such as a degenerate Result node that replaces what
+ * would otherwise have been a join, and (2) Append and MergeAppend nodes
+ * implementing a partitionwise scan or a partitionwise join. Said
+ * differently, scans are the leaves of the join tree for a single join
+ * problem.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_scan.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_SCAN_H
+#define PGPA_SCAN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+
+/*
+ * Scan strategies.
+ *
+ * PGPA_SCAN_ORDINARY is any scan strategy that isn't interesting to us
+ * because there is no meaningful planner decision involved. For example,
+ * the only way to scan a subquery is a SubqueryScan, and the only way to
+ * scan a VALUES construct is a ValuesScan. We need not care exactly which
+ * type of planner node was used in such cases, because the same thing will
+ * happen when replanning.
+ *
+ * PGPA_SCAN_ORDINARY also includes Result nodes that correspond to scans
+ * or even joins that are proved empty. We don't know whether or not the scan
+ * or join will still be provably empty at replanning time, but if it is,
+ * then no scan-type advice is needed, and if it's not, we can't recommend
+ * a scan type based on the current plan.
+ *
+ * PGPA_SCAN_PARTITIONWISE also lumps together scans and joins: this can
+ * be either a partitionwise scan of a partitioned table or a partitionwise
+ * join between several partitioned tables. Note that all decisions about
+ * whether or not to use partitionwise join are meaningful: no matter what
+ * we decided this time, we could do more or fewer things partitionwise the
+ * next time.
+ *
+ * PGPA_SCAN_FOREIGN is only used when there's more than one relation involved;
+ * a single-table foreign scan is classified as ordinary, since there is no
+ * decision to make in that case.
+ *
+ * Other scan strategies map one-to-one to plan nodes.
+ */
+typedef enum
+{
+	PGPA_SCAN_ORDINARY = 0,
+	PGPA_SCAN_SEQ,
+	PGPA_SCAN_BITMAP_HEAP,
+	PGPA_SCAN_FOREIGN,
+	PGPA_SCAN_INDEX,
+	PGPA_SCAN_INDEX_ONLY,
+	PGPA_SCAN_PARTITIONWISE,
+	PGPA_SCAN_TID
+	/* update NUM_PGPA_SCAN_STRATEGY if you add anything here */
+} pgpa_scan_strategy;
+
+#define NUM_PGPA_SCAN_STRATEGY	((int) PGPA_SCAN_TID + 1)
+
+/*
+ * All of the details we need regarding a scan.
+ */
+typedef struct pgpa_scan
+{
+	Plan	   *plan;
+	pgpa_scan_strategy strategy;
+	Bitmapset  *relids;
+} pgpa_scan;
+
+extern pgpa_scan *pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
+								  ElidedNode *elided_node,
+								  bool beneath_any_gather,
+								  bool within_join_problem);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_scanner.l b/contrib/pg_plan_advice/pgpa_scanner.l
new file mode 100644
index 00000000000..3b3be6eb727
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_scanner.l
@@ -0,0 +1,297 @@
+%top{
+/*
+ * Scanner for plan advice
+ *
+ * Copyright (c) 2000-2026, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_scanner.l
+ */
+#include "postgres.h"
+
+#include "common/string.h"
+#include "nodes/miscnodes.h"
+#include "parser/scansup.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Extra data that we pass around when during scanning.
+ *
+ * 'litbuf' is used to implement the <xd> exclusive state, which handles
+ * double-quoted identifiers.
+ */
+typedef struct pgpa_yy_extra_type
+{
+	StringInfoData	litbuf;
+} pgpa_yy_extra_type;
+
+}
+
+%{
+/* LCOV_EXCL_START */
+
+#define YY_DECL \
+	extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result, \
+						  char **parse_error_msg_p, yyscan_t yyscanner)
+
+/* No reason to constrain amount of data slurped */
+#define YY_READ_BUF_SIZE 16777216
+
+/* Avoid exit() on fatal scanner errors (a bit ugly -- see yy_fatal_error) */
+#undef fprintf
+#define fprintf(file, fmt, msg)  fprintf_to_ereport(fmt, msg)
+
+static void
+fprintf_to_ereport(const char *fmt, const char *msg)
+{
+	ereport(ERROR, (errmsg_internal("%s", msg)));
+}
+%}
+
+%option reentrant
+%option bison-bridge
+%option 8bit
+%option never-interactive
+%option nodefault
+%option noinput
+%option nounput
+%option noyywrap
+%option noyyalloc
+%option noyyrealloc
+%option noyyfree
+%option warn
+%option prefix="pgpa_yy"
+%option extra-type="pgpa_yy_extra_type *"
+
+/*
+ * What follows is a severely stripped-down version of the core scanner. We
+ * only care about recognizing identifiers with or without identifier quoting
+ * (i.e. double-quoting), decimal integers, and a small handful of other
+ * things. Keep these rules in sync with src/backend/parser/scan.l. As in that
+ * file, we use an exclusive state called 'xc' for C-style comments, and an
+ * exclusive state called 'xd' for double-quoted identifiers.
+ */
+%x xc
+%x xd
+
+ident_start		[A-Za-z\200-\377_]
+ident_cont		[A-Za-z\200-\377_0-9\$]
+
+identifier		{ident_start}{ident_cont}*
+
+decdigit		[0-9]
+decinteger		{decdigit}(_?{decdigit})*
+
+space			[ \t\n\r\f\v]
+whitespace		{space}+
+
+dquote			\"
+xdstart			{dquote}
+xdstop			{dquote}
+xddouble		{dquote}{dquote}
+xdinside		[^"]+
+
+xcstart			\/\*
+xcstop			\*+\/
+xcinside		[^*/]+
+
+%%
+
+{whitespace}	{ /* ignore */ }
+
+{identifier}	{
+					char   *str;
+					bool	fail;
+					pgpa_advice_tag_type	tag;
+
+					/*
+					 * Unlike the core scanner, we don't truncate identifiers
+					 * here. There is no obvious reason to do so.
+					 */
+					str = downcase_identifier(yytext, yyleng, false, false);
+					yylval->str = str;
+
+					/*
+					 * If it's not a tag, just return TOK_IDENT; else, return
+					 * a token type based on how further parsing should
+					 * proceed.
+					 */
+					tag = pgpa_parse_advice_tag(str, &fail);
+					if (fail)
+						return TOK_IDENT;
+					else if (tag == PGPA_TAG_JOIN_ORDER)
+						return TOK_TAG_JOIN_ORDER;
+					else if (tag == PGPA_TAG_INDEX_SCAN ||
+							 tag == PGPA_TAG_INDEX_ONLY_SCAN)
+						return TOK_TAG_INDEX;
+					else if (tag == PGPA_TAG_SEQ_SCAN ||
+							 tag == PGPA_TAG_TID_SCAN ||
+							 tag == PGPA_TAG_BITMAP_HEAP_SCAN ||
+							 tag == PGPA_TAG_NO_GATHER)
+						return TOK_TAG_SIMPLE;
+					else
+						return TOK_TAG_GENERIC;
+				}
+
+{decinteger}	{
+					char   *endptr;
+
+					errno = 0;
+					yylval->integer = strtoint(yytext, &endptr, 10);
+					if (*endptr != '\0' || errno == ERANGE)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "integer out of range");
+					return TOK_INTEGER;
+				}
+
+{xcstart}		{
+					BEGIN(xc);
+				}
+
+{xdstart}		{
+					BEGIN(xd);
+					resetStringInfo(&yyextra->litbuf);
+				}
+
+.				{ return yytext[0]; }
+
+<xc>{xcstop}	{
+					BEGIN(INITIAL);
+				}
+
+<xc>{xcinside}	{
+					/* discard multiple characters without slash or asterisk */
+				}
+
+<xc>.			{
+					/*
+					 * Discard any single character. flex prefers longer
+					 * matches, so this rule will never be picked when we could
+					 * have matched xcstop.
+					 *
+					 * NB: At present, we don't bother to support nested
+					 * C-style comments here, but this logic could be extended
+					 * if that restriction poses a problem.
+					 */
+				}
+
+<xc><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated comment");
+				}
+
+<xd>{xdstop}	{
+					BEGIN(INITIAL);
+					if (yyextra->litbuf.len == 0)
+						pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+									 "zero-length delimited identifier");
+					yylval->str = pstrdup(yyextra->litbuf.data);
+					return TOK_IDENT;
+				}
+
+<xd>{xddouble}	{
+					appendStringInfoChar(&yyextra->litbuf, '"');
+				}
+
+<xd>{xdinside}	{
+					appendBinaryStringInfo(&yyextra->litbuf, yytext, yyleng);
+				}
+
+<xd><<EOF>>		{
+					BEGIN(INITIAL);
+					pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+								 "unterminated quoted identifier");
+				}
+
+%%
+
+/* LCOV_EXCL_STOP */
+
+/*
+ * Handler for errors while scanning or parsing advice.
+ *
+ * bison passes the error message to us via 'message', and the context is
+ * available via the 'yytext' macro. We assemble those values into a final
+ * error text and then arrange to pass it back to the caller of pgpa_yyparse()
+ * by storing it into *parse_error_msg_p.
+ */
+void
+pgpa_yyerror(List **result, char **parse_error_msg_p, yyscan_t yyscanner,
+			 const char *message)
+{
+	struct yyguts_t *yyg = (struct yyguts_t *) yyscanner;	/* needed for yytext
+															 * macro */
+
+
+	/* report only the first error in a parse operation */
+	if (*parse_error_msg_p)
+		return;
+
+	if (yytext[0])
+		*parse_error_msg_p = psprintf("%s at or near \"%s\"", message, yytext);
+	else
+		*parse_error_msg_p = psprintf("%s at end of input", message);
+}
+
+/*
+ * Initialize the advice scanner.
+ *
+ * This should be called before parsing begins.
+ */
+void
+pgpa_scanner_init(const char *str, yyscan_t *yyscannerp)
+{
+	yyscan_t	yyscanner;
+	pgpa_yy_extra_type	*yyext = palloc0_object(pgpa_yy_extra_type);
+
+	if (yylex_init(yyscannerp) != 0)
+		elog(ERROR, "yylex_init() failed: %m");
+
+	yyscanner = *yyscannerp;
+
+	initStringInfo(&yyext->litbuf);
+	pgpa_yyset_extra(yyext, yyscanner);
+
+	yy_scan_string(str, yyscanner);
+}
+
+
+/*
+ * Shut down the advice scanner.
+ *
+ * This should be called after parsing is complete.
+ */
+void
+pgpa_scanner_finish(yyscan_t yyscanner)
+{
+	yylex_destroy(yyscanner);
+}
+
+/*
+ * Interface functions to make flex use palloc() instead of malloc().
+ * It'd be better to make these static, but flex insists otherwise.
+ */
+
+void *
+yyalloc(yy_size_t size, yyscan_t yyscanner)
+{
+	return palloc(size);
+}
+
+void *
+yyrealloc(void *ptr, yy_size_t size, yyscan_t yyscanner)
+{
+	if (ptr)
+		return repalloc(ptr, size);
+	else
+		return palloc(size);
+}
+
+void
+yyfree(void *ptr, yyscan_t yyscanner)
+{
+	if (ptr)
+		pfree(ptr);
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
new file mode 100644
index 00000000000..634ec5c4c6e
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -0,0 +1,516 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.c
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * This name comes from the English expression "trove of advice", which
+ * means a collection of wisdom. This slightly unusual term is chosen
+ * partly because it seems to fit and partly because it's not presently
+ * used for anything else, making it easy to grep. Note that, while we
+ * don't know whether the provided advice is actually wise, it's not our
+ * job to question the user's choices.
+ *
+ * The goal of this module is to make it easy to locate the specific
+ * bits of advice that pertain to any given part of a query, or to
+ * determine that there are none.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_trove.h"
+
+#include "common/hashfn_unstable.h"
+
+/*
+ * An advice trove is organized into a series of "slices", each of which
+ * contains information about one topic e.g. scan methods. Each slice consists
+ * of an array of trove entries plus a hash table that we can use to determine
+ * which ones are relevant to a particular part of the query.
+ */
+typedef struct pgpa_trove_slice
+{
+	unsigned	nallocated;
+	unsigned	nused;
+	pgpa_trove_entry *entries;
+	struct pgpa_trove_entry_hash *hash;
+} pgpa_trove_slice;
+
+/*
+ * Scan advice is stored into 'scan'; join advice is stored into 'join'; and
+ * advice that can apply to both cases is stored into 'rel'. This lets callers
+ * ask just for what's relevant. These slices correspond to the possible values
+ * of pgpa_trove_lookup_type.
+ */
+struct pgpa_trove
+{
+	pgpa_trove_slice join;
+	pgpa_trove_slice rel;
+	pgpa_trove_slice scan;
+};
+
+/*
+ * We're going to build a hash table to allow clients of this module to find
+ * relevant advice for a given part of the query quickly. However, we're going
+ * to use only three of the five key fields as hash keys. There are two reasons
+ * for this.
+ *
+ * First, it's allowable to set partition_schema to NULL to match a partition
+ * with the correct name in any schema.
+ *
+ * Second, we expect the "occurrence" and "partition_schema" portions of the
+ * relation identifiers to be mostly uninteresting. Most of the time, the
+ * occurrence field will be 1 and the partition_schema values will all be the
+ * same. Even when there is some variation, the absolute number of entries
+ * that have the same values for all three of these key fields should be
+ * quite small.
+ */
+typedef struct
+{
+	const char *alias_name;
+	const char *partition_name;
+	const char *plan_name;
+} pgpa_trove_entry_key;
+
+typedef struct
+{
+	pgpa_trove_entry_key key;
+	int			status;
+	Bitmapset  *indexes;
+} pgpa_trove_entry_element;
+
+static uint32 pgpa_trove_entry_hash_key(pgpa_trove_entry_key key);
+
+static inline bool
+pgpa_trove_entry_compare_key(pgpa_trove_entry_key a, pgpa_trove_entry_key b)
+{
+	if (strcmp(a.alias_name, b.alias_name) != 0)
+		return false;
+
+	if (!strings_equal_or_both_null(a.partition_name, b.partition_name))
+		return false;
+
+	if (!strings_equal_or_both_null(a.plan_name, b.plan_name))
+		return false;
+
+	return true;
+}
+
+#define SH_PREFIX			pgpa_trove_entry
+#define SH_ELEMENT_TYPE		pgpa_trove_entry_element
+#define SH_KEY_TYPE			pgpa_trove_entry_key
+#define SH_KEY				key
+#define SH_HASH_KEY(tb, key)	pgpa_trove_entry_hash_key(key)
+#define	SH_EQUAL(tb, a, b)	pgpa_trove_entry_compare_key(a, b)
+#define SH_SCOPE			static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static void pgpa_init_trove_slice(pgpa_trove_slice *tslice);
+static void pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+									pgpa_advice_tag_type tag,
+									pgpa_advice_target *target);
+static void pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash,
+								   pgpa_advice_target *target,
+								   int index);
+static Bitmapset *pgpa_trove_slice_lookup(pgpa_trove_slice *tslice,
+										  pgpa_identifier *rid);
+
+/*
+ * Build a trove of advice from a list of advice items.
+ *
+ * Caller can obtain a list of advice items to pass to this function by
+ * calling pgpa_parse().
+ */
+pgpa_trove *
+pgpa_build_trove(List *advice_items)
+{
+	pgpa_trove *trove = palloc_object(pgpa_trove);
+
+	pgpa_init_trove_slice(&trove->join);
+	pgpa_init_trove_slice(&trove->rel);
+	pgpa_init_trove_slice(&trove->scan);
+
+	foreach_ptr(pgpa_advice_item, item, advice_items)
+	{
+		switch (item->tag)
+		{
+			case PGPA_TAG_JOIN_ORDER:
+				{
+					pgpa_advice_target *target;
+
+					/*
+					 * For most advice types, each element in the top-level
+					 * list is a separate target, but it's most convenient to
+					 * regard the entirety of a JOIN_ORDER specification as a
+					 * single target. Since it wasn't represented that way
+					 * during parsing, build a surrogate object now.
+					 */
+					target = palloc0_object(pgpa_advice_target);
+					target->ttype = PGPA_TARGET_ORDERED_LIST;
+					target->children = item->targets;
+
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_BITMAP_HEAP_SCAN:
+			case PGPA_TAG_INDEX_ONLY_SCAN:
+			case PGPA_TAG_INDEX_SCAN:
+			case PGPA_TAG_SEQ_SCAN:
+			case PGPA_TAG_TID_SCAN:
+
+				/*
+				 * Scan advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					/*
+					 * For now, all of our scan types target single relations,
+					 * but in the future this might not be true, e.g. a custom
+					 * scan could replace a join.
+					 */
+					Assert(target->ttype == PGPA_TARGET_IDENTIFIER);
+					pgpa_trove_add_to_slice(&trove->scan,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_FOREIGN_JOIN:
+			case PGPA_TAG_HASH_JOIN:
+			case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			case PGPA_TAG_MERGE_JOIN_PLAIN:
+			case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			case PGPA_TAG_NESTED_LOOP_PLAIN:
+			case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			case PGPA_TAG_SEMIJOIN_UNIQUE:
+
+				/*
+				 * Join strategy advice.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->join,
+											item->tag, target);
+				}
+				break;
+
+			case PGPA_TAG_PARTITIONWISE:
+			case PGPA_TAG_GATHER:
+			case PGPA_TAG_GATHER_MERGE:
+			case PGPA_TAG_NO_GATHER:
+
+				/*
+				 * Advice about a RelOptInfo relevant to both scans and joins.
+				 */
+				foreach_ptr(pgpa_advice_target, target, item->targets)
+				{
+					pgpa_trove_add_to_slice(&trove->rel,
+											item->tag, target);
+				}
+				break;
+		}
+	}
+
+	return trove;
+}
+
+/*
+ * Search a trove of advice for relevant entries.
+ *
+ * All parameters are input parameters except for *result, which is an output
+ * parameter used to return results to the caller.
+ */
+void
+pgpa_trove_lookup(pgpa_trove *trove, pgpa_trove_lookup_type type,
+				  int nrids, pgpa_identifier *rids, pgpa_trove_result *result)
+{
+	pgpa_trove_slice *tslice;
+	Bitmapset  *indexes;
+
+	Assert(nrids > 0);
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	indexes = pgpa_trove_slice_lookup(tslice, &rids[0]);
+	for (int i = 1; i < nrids; ++i)
+	{
+		Bitmapset  *other_indexes;
+
+		/*
+		 * If the caller is asking about two relations that aren't part of the
+		 * same subquery, they've messed up.
+		 */
+		Assert(strings_equal_or_both_null(rids[0].plan_name,
+										  rids[i].plan_name));
+
+		other_indexes = pgpa_trove_slice_lookup(tslice, &rids[i]);
+		indexes = bms_union(indexes, other_indexes);
+	}
+
+	result->entries = tslice->entries;
+	result->indexes = indexes;
+}
+
+/*
+ * Return all entries in a trove slice to the caller.
+ *
+ * The first two arguments are input arguments, and the remainder are output
+ * arguments.
+ */
+void
+pgpa_trove_lookup_all(pgpa_trove *trove, pgpa_trove_lookup_type type,
+					  pgpa_trove_entry **entries, int *nentries)
+{
+	pgpa_trove_slice *tslice;
+
+	if (type == PGPA_TROVE_LOOKUP_SCAN)
+		tslice = &trove->scan;
+	else if (type == PGPA_TROVE_LOOKUP_JOIN)
+		tslice = &trove->join;
+	else
+		tslice = &trove->rel;
+
+	*entries = tslice->entries;
+	*nentries = tslice->nused;
+}
+
+/*
+ * Convert a trove entry to an item of plan advice that would produce it.
+ */
+char *
+pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
+{
+	StringInfoData buf;
+
+	initStringInfo(&buf);
+	appendStringInfo(&buf, "%s", pgpa_cstring_advice_tag(entry->tag));
+
+	/* JOIN_ORDER tags are transformed by pgpa_build_trove; undo that here */
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, '(');
+	else
+		Assert(entry->target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	pgpa_format_advice_target(&buf, entry->target);
+
+	if (entry->target->itarget != NULL)
+	{
+		appendStringInfoChar(&buf, ' ');
+		pgpa_format_index_target(&buf, entry->target->itarget);
+	}
+
+	if (entry->tag != PGPA_TAG_JOIN_ORDER)
+		appendStringInfoChar(&buf, ')');
+
+	return buf.data;
+}
+
+/*
+ * Set PGPA_TE_* flags on a set of trove entries.
+ */
+void
+pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
+{
+	int			i = -1;
+
+	while ((i = bms_next_member(indexes, i)) >= 0)
+	{
+		pgpa_trove_entry *entry = &entries[i];
+
+		entry->flags |= flags;
+	}
+}
+
+/*
+ * Append a string representation of the specified PGPA_TE_* flags to the
+ * given StringInfo.
+ */
+void
+pgpa_trove_append_flags(StringInfo buf, int flags)
+{
+	if ((flags & PGPA_TE_MATCH_FULL) != 0)
+	{
+		Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+		appendStringInfo(buf, "matched");
+	}
+	else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+		appendStringInfo(buf, "partially matched");
+	else
+		appendStringInfo(buf, "not matched");
+	if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+		appendStringInfo(buf, ", inapplicable");
+	if ((flags & PGPA_TE_CONFLICTING) != 0)
+		appendStringInfo(buf, ", conflicting");
+	if ((flags & PGPA_TE_FAILED) != 0)
+		appendStringInfo(buf, ", failed");
+}
+
+/*
+ * Add a new advice target to an existing pgpa_trove_slice object.
+ */
+static void
+pgpa_trove_add_to_slice(pgpa_trove_slice *tslice,
+						pgpa_advice_tag_type tag,
+						pgpa_advice_target *target)
+{
+	pgpa_trove_entry *entry;
+
+	if (tslice->nused >= tslice->nallocated)
+	{
+		int			new_allocated;
+
+		new_allocated = tslice->nallocated * 2;
+		tslice->entries = repalloc_array(tslice->entries, pgpa_trove_entry,
+										 new_allocated);
+		tslice->nallocated = new_allocated;
+	}
+
+	entry = &tslice->entries[tslice->nused];
+	entry->tag = tag;
+	entry->target = target;
+	entry->flags = 0;
+
+	pgpa_trove_add_to_hash(tslice->hash, target, tslice->nused);
+
+	tslice->nused++;
+}
+
+/*
+ * Update the hash table for a newly-added advice target.
+ */
+static void
+pgpa_trove_add_to_hash(pgpa_trove_entry_hash *hash, pgpa_advice_target *target,
+					   int index)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	bool		found;
+
+	/* For non-identifiers, add entries for all descendants. */
+	if (target->ttype != PGPA_TARGET_IDENTIFIER)
+	{
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			pgpa_trove_add_to_hash(hash, child_target, index);
+		}
+		return;
+	}
+
+	/* Sanity checks. */
+	Assert(target->rid.occurrence > 0);
+	Assert(target->rid.alias_name != NULL);
+
+	/* Add an entry for this relation identifier. */
+	key.alias_name = target->rid.alias_name;
+	key.partition_name = target->rid.partrel;
+	key.plan_name = target->rid.plan_name;
+	element = pgpa_trove_entry_insert(hash, key, &found);
+	if (!found)
+		element->indexes = NULL;
+	element->indexes = bms_add_member(element->indexes, index);
+}
+
+/*
+ * Create and initialize a new pgpa_trove_slice object.
+ */
+static void
+pgpa_init_trove_slice(pgpa_trove_slice *tslice)
+{
+	/*
+	 * In an ideal world, we'll make tslice->nallocated big enough that the
+	 * array and hash table will be large enough to contain the number of
+	 * advice items in this trove slice, but a generous default value is not
+	 * good for performance, because pgpa_init_trove_slice() has to zero an
+	 * amount of memory proportional to tslice->nallocated. Hence, we keep the
+	 * starting value quite small, on the theory that advice strings will
+	 * often be relatively short.
+	 */
+	tslice->nallocated = 16;
+	tslice->nused = 0;
+	tslice->entries = palloc_array(pgpa_trove_entry, tslice->nallocated);
+	tslice->hash = pgpa_trove_entry_create(CurrentMemoryContext,
+										   tslice->nallocated, NULL);
+}
+
+/*
+ * Fast hash function for a key consisting of alias_name, partition_name,
+ * and plan_name.
+ */
+static uint32
+pgpa_trove_entry_hash_key(pgpa_trove_entry_key key)
+{
+	fasthash_state hs;
+	int			sp_len;
+
+	fasthash_init(&hs, 0);
+
+	/* alias_name may not be NULL */
+	sp_len = fasthash_accum_cstring(&hs, key.alias_name);
+
+	/* partition_name and plan_name, however, can be NULL */
+	if (key.partition_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.partition_name);
+	if (key.plan_name != NULL)
+		sp_len += fasthash_accum_cstring(&hs, key.plan_name);
+
+	/*
+	 * hashfn_unstable.h recommends using string length as tweak. It's not
+	 * clear to me what to do if there are multiple strings, so for now I'm
+	 * just using the total of all of the lengths.
+	 */
+	return fasthash_final32(&hs, sp_len);
+}
+
+/*
+ * Look for matching entries.
+ */
+static Bitmapset *
+pgpa_trove_slice_lookup(pgpa_trove_slice *tslice, pgpa_identifier *rid)
+{
+	pgpa_trove_entry_key key;
+	pgpa_trove_entry_element *element;
+	Bitmapset  *result = NULL;
+
+	Assert(rid->occurrence >= 1);
+
+	key.alias_name = rid->alias_name;
+	key.partition_name = rid->partrel;
+	key.plan_name = rid->plan_name;
+
+	element = pgpa_trove_entry_lookup(tslice->hash, key);
+
+	if (element != NULL)
+	{
+		int			i = -1;
+
+		while ((i = bms_next_member(element->indexes, i)) >= 0)
+		{
+			pgpa_trove_entry *entry = &tslice->entries[i];
+
+			/*
+			 * We know that this target or one of its descendants matches the
+			 * identifier on the three key fields above, but we don't know
+			 * which descendant or whether the occurrence and schema also
+			 * match.
+			 */
+			if (pgpa_identifier_matches_target(rid, entry->target))
+				result = bms_add_member(result, i);
+		}
+	}
+
+	return result;
+}
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
new file mode 100644
index 00000000000..22fe3a620f7
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -0,0 +1,114 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_trove.h
+ *	  All of the advice given for a particular query, appropriately
+ *    organized for convenient access.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_trove.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_TROVE_H
+#define PGPA_TROVE_H
+
+#include "pgpa_ast.h"
+
+#include "nodes/bitmapset.h"
+
+typedef struct pgpa_trove pgpa_trove;
+
+/*
+ * Flags that can be set on a pgpa_trove_entry to indicate what happened when
+ * trying to plan using advice.
+ *
+ * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_TE_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_TE_MATCH_PARTIAL		0x0001
+#define PGPA_TE_MATCH_FULL			0x0002
+#define PGPA_TE_INAPPLICABLE		0x0004
+#define PGPA_TE_CONFLICTING			0x0008
+#define PGPA_TE_FAILED				0x0010
+
+/*
+ * Each entry in a trove of advice represents the application of a tag to
+ * a single target.
+ */
+typedef struct pgpa_trove_entry
+{
+	pgpa_advice_tag_type tag;
+	pgpa_advice_target *target;
+	int			flags;
+} pgpa_trove_entry;
+
+/*
+ * What kind of information does the caller want to find in a trove?
+ *
+ * PGPA_TROVE_LOOKUP_SCAN means we're looking for scan advice.
+ *
+ * PGPA_TROVE_LOOKUP_JOIN means we're looking for join-related advice.
+ * This includes join order advice, join method advice, and semijoin-uniqueness
+ * advice.
+ *
+ * PGPA_TROVE_LOOKUP_REL means we're looking for general advice about this
+ * a RelOptInfo that may correspond to either a scan or a join. This includes
+ * gather-related advice and partitionwise advice. Note that partitionwise
+ * advice might seem like join advice, but that's not a helpful way of viewing
+ * the matter because (1) partitionwise advice is also relevant at the scan
+ * level and (2) other types of join advice affect only what to do from
+ * join_path_setup_hook, but partitionwise advice affects what to do in
+ * joinrel_setup_hook.
+ */
+typedef enum pgpa_trove_lookup_type
+{
+	PGPA_TROVE_LOOKUP_JOIN,
+	PGPA_TROVE_LOOKUP_REL,
+	PGPA_TROVE_LOOKUP_SCAN
+} pgpa_trove_lookup_type;
+
+/*
+ * This struct is used to store the result of a trove lookup. For each member
+ * of "indexes", the entry at the corresponding offset within "entries" is one
+ * of the results.
+ */
+typedef struct pgpa_trove_result
+{
+	pgpa_trove_entry *entries;
+	Bitmapset  *indexes;
+} pgpa_trove_result;
+
+extern pgpa_trove *pgpa_build_trove(List *advice_items);
+extern void pgpa_trove_lookup(pgpa_trove *trove,
+							  pgpa_trove_lookup_type type,
+							  int nrids,
+							  pgpa_identifier *rids,
+							  pgpa_trove_result *result);
+extern void pgpa_trove_lookup_all(pgpa_trove *trove,
+								  pgpa_trove_lookup_type type,
+								  pgpa_trove_entry **entries,
+								  int *nentries);
+extern char *pgpa_cstring_trove_entry(pgpa_trove_entry *entry);
+extern void pgpa_trove_set_flags(pgpa_trove_entry *entries,
+								 Bitmapset *indexes, int flags);
+extern void pgpa_trove_append_flags(StringInfo buf, int flags);
+
+#endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
new file mode 100644
index 00000000000..b95ba7a75fa
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -0,0 +1,1029 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.c
+ *	  Main entrypoints for analyzing a plan to generate an advice string
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/plannodes.h"
+#include "parser/parsetree.h"
+#include "utils/lsyscache.h"
+
+static void pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+								  bool within_join_problem,
+								  pgpa_join_unroller *join_unroller,
+								  List *active_query_features,
+								  bool beneath_any_gather);
+static Bitmapset *pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+											 pgpa_unrolled_join *ujoin);
+
+static pgpa_query_feature *pgpa_add_feature(pgpa_plan_walker_context *walker,
+											pgpa_qf_type type,
+											Plan *plan);
+
+static void pgpa_qf_add_rti(List *active_query_features, Index rti);
+static void pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids);
+static void pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan,
+								  List *rtable);
+
+static bool pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+										   Index rtable_length,
+										   pgpa_identifier *rt_identifiers,
+										   pgpa_advice_target *target,
+										   bool toplevel);
+static bool pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+												  Index rtable_length,
+												  pgpa_identifier *rt_identifiers,
+												  pgpa_advice_target *target);
+static pgpa_scan *pgpa_walker_find_scan(pgpa_plan_walker_context *walker,
+										pgpa_scan_strategy strategy,
+										Bitmapset *relids);
+static bool pgpa_walker_index_target_matches_plan(pgpa_index_target *itarget,
+												  Plan *plan);
+static bool pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+										 pgpa_qf_type type,
+										 Bitmapset *relids);
+static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+									  pgpa_join_strategy strategy,
+									  Bitmapset *relids);
+static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+										   Bitmapset *relids);
+
+/*
+ * Top-level entrypoint for the plan tree walk.
+ *
+ * Populates walker based on a traversal of the Plan trees in pstmt.
+ *
+ * sj_unique_rels is a list of pgpa_sj_unique_rel objects, one for each
+ * relation we considered making unique as part of semijoin planning.
+ */
+void
+pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
+				 List *sj_unique_rels)
+{
+	ListCell   *lc;
+	List	   *sj_unique_rtis = NULL;
+	List	   *sj_nonunique_qfs = NULL;
+
+	/* Initialization. */
+	memset(walker, 0, sizeof(pgpa_plan_walker_context));
+	walker->pstmt = pstmt;
+
+	/* Walk the main plan tree. */
+	pgpa_walk_recursively(walker, pstmt->planTree, false, NULL, NIL, false);
+
+	/* Main plan tree walk won't reach subplans, so walk those. */
+	foreach(lc, pstmt->subplans)
+	{
+		Plan	   *plan = lfirst(lc);
+
+		if (plan != NULL)
+			pgpa_walk_recursively(walker, plan, false, NULL, NIL, false);
+	}
+
+	/* Adjust RTIs from sj_unique_rels for the flattened range table. */
+	foreach_ptr(pgpa_sj_unique_rel, ur, sj_unique_rels)
+	{
+		int			rtindex = -1;
+		int			rtoffset = 0;
+		bool		dummy = false;
+		Bitmapset  *relids = NULL;
+
+		/* If this is a subplan, find the range table offset. */
+		if (ur->plan_name != NULL)
+		{
+			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
+			{
+				if (strcmp(ur->plan_name, rtinfo->plan_name) == 0)
+				{
+					rtoffset = rtinfo->rtoffset;
+					dummy = rtinfo->dummy;
+					break;
+				}
+			}
+
+			if (rtoffset == 0)
+				elog(ERROR, "no rtoffset for plan %s", ur->plan_name);
+		}
+
+		/* If this entry pertains to a dummy subquery, ignore it. */
+		if (dummy)
+			continue;
+
+		/* Offset each entry from the original set. */
+		while ((rtindex = bms_next_member(ur->relids, rtindex)) >= 0)
+			relids = bms_add_member(relids, rtindex + rtoffset);
+
+		/* Store the resulting set. */
+		sj_unique_rtis = lappend(sj_unique_rtis, relids);
+	}
+
+	/*
+	 * Remove any non-unique semijoin query features for which making the rel
+	 * unique wasn't considered.
+	 */
+	foreach_ptr(pgpa_query_feature, qf,
+				walker->query_features[PGPAQF_SEMIJOIN_NON_UNIQUE])
+	{
+		if (list_member(sj_unique_rtis, qf->relids))
+			sj_nonunique_qfs = lappend(sj_nonunique_qfs, qf);
+	}
+	walker->query_features[PGPAQF_SEMIJOIN_NON_UNIQUE] = sj_nonunique_qfs;
+
+	/*
+	 * If we find any cases where analysis of the Plan tree shows that the
+	 * semijoin was made unique but this possibility was never observed to be
+	 * considered during planning, then we have a bug somewhere.
+	 */
+	foreach_ptr(pgpa_query_feature, qf,
+				walker->query_features[PGPAQF_SEMIJOIN_UNIQUE])
+	{
+		if (!list_member(sj_unique_rtis, qf->relids))
+		{
+			StringInfoData buf;
+
+			initStringInfo(&buf);
+			outBitmapset(&buf, qf->relids);
+			elog(ERROR,
+				 "unique semijoin found for relids %s but not observed during planning",
+				 buf.data);
+		}
+	}
+
+	/*
+	 * It's possible for a Gather or Gather Merge query feature to find no
+	 * RTIs when partitionwise aggregation is in use. We shouldn't emit
+	 * something like GATHER_MERGE(()), so instead emit nothing. This means
+	 * that we won't advise either GATHER or GATHER_MERGE or NO_GATHER in such
+	 * cases, which might be something we want to improve in the future.
+	 *
+	 * (Should the Partial Aggregates in such a case be created in an
+	 * UPPERREL_GROUP_AGG with a non-empty relid set? Right now that doesn't
+	 * happen, but it seems like it would make life easier for us if it did.)
+	 */
+	for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+	{
+		List	   *query_features = NIL;
+
+		foreach_ptr(pgpa_query_feature, qf, walker->query_features[t])
+		{
+			if (qf->relids != NULL)
+				query_features = lappend(query_features, qf);
+			else
+				Assert(t == PGPAQF_GATHER || t == PGPAQF_GATHER_MERGE);
+		}
+
+		walker->query_features[t] = query_features;
+	}
+}
+
+/*
+ * Main workhorse for the plan tree walk.
+ *
+ * If within_join_problem is true, we encountered a join at some higher level
+ * of the tree walk and haven't yet descended out of the portion of the plan
+ * tree that is part of that same join problem. We're no longer in the same
+ * join problem if (1) we cross into a different subquery or (2) we descend
+ * through an Append or MergeAppend node, below which any further joins would
+ * be partitionwise joins planned separately from the outer join problem.
+ *
+ * If join_unroller != NULL, the join unroller code expects us to find a join
+ * that should be unrolled into that object. This implies that we're within a
+ * join problem, but the reverse is not true: when we've traversed all the
+ * joins but are still looking for the scan that is the leaf of the join tree,
+ * join_unroller will be NULL but within_join_problem will be true.
+ *
+ * Each element of active_query_features corresponds to some item of advice
+ * that needs to enumerate all the relations it affects. We add RTIs we find
+ * during tree traversal to each of these query features.
+ *
+ * If beneath_any_gather == true, some higher level of the tree traversal found
+ * a Gather or Gather Merge node.
+ */
+static void
+pgpa_walk_recursively(pgpa_plan_walker_context *walker, Plan *plan,
+					  bool within_join_problem,
+					  pgpa_join_unroller *join_unroller,
+					  List *active_query_features,
+					  bool beneath_any_gather)
+{
+	pgpa_join_unroller *outer_join_unroller = NULL;
+	pgpa_join_unroller *inner_join_unroller = NULL;
+	bool		join_unroller_toplevel = false;
+	ListCell   *lc;
+	List	   *extraplans = NIL;
+	List	   *elided_nodes = NIL;
+
+	Assert(within_join_problem || join_unroller == NULL);
+
+	/*
+	 * Check the future_query_features list to see whether this was previously
+	 * identified as a plan node that needs to be treated as a query feature.
+	 * We must do this before handling elided nodes, because if there's an
+	 * elided node associated with a future query feature, the RTIs associated
+	 * with the elided node should be the only ones attributed to the query
+	 * feature.
+	 */
+	foreach_ptr(pgpa_query_feature, qf, walker->future_query_features)
+	{
+		if (qf->plan == plan)
+		{
+			active_query_features = list_copy(active_query_features);
+			active_query_features = lappend(active_query_features, qf);
+			walker->future_query_features =
+				list_delete_ptr(walker->future_query_features, qf);
+			break;
+		}
+	}
+
+	/*
+	 * Find all elided nodes for this Plan node.
+	 */
+	foreach_node(ElidedNode, n, walker->pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_nodes = lappend(elided_nodes, n);
+	}
+
+	/* If we found any elided_nodes, handle them. */
+	if (elided_nodes != NIL)
+	{
+		int			num_elided_nodes = list_length(elided_nodes);
+		ElidedNode *last_elided_node;
+
+		/*
+		 * RTIs for the final -- and thus logically uppermost -- elided node
+		 * should be collected for query features passed down by the caller.
+		 * However, elided nodes act as barriers to query features, which
+		 * means that (1) the remaining elided nodes, if any, should be
+		 * ignored for purposes of query features and (2) the list of active
+		 * query features should be reset to empty so that we do not add RTIs
+		 * from the plan node that is logically beneath the elided node to the
+		 * query features passed down from the caller.
+		 */
+		last_elided_node = list_nth(elided_nodes, num_elided_nodes - 1);
+		pgpa_qf_add_rtis(active_query_features,
+						 pgpa_filter_out_join_relids(last_elided_node->relids,
+													 walker->pstmt->rtable));
+		active_query_features = NIL;
+
+		/*
+		 * If we're within a join problem, the join_unroller is responsible
+		 * for building the scan for the final elided node, so throw it out.
+		 */
+		if (within_join_problem)
+			elided_nodes = list_truncate(elided_nodes, num_elided_nodes - 1);
+
+		/* Build scans for all (or the remaining) elided nodes. */
+		foreach_node(ElidedNode, elided_node, elided_nodes)
+		{
+			(void) pgpa_build_scan(walker, plan, elided_node,
+								   beneath_any_gather, within_join_problem);
+		}
+
+		/*
+		 * If there were any elided nodes, then everything beneath those nodes
+		 * is not part of the same join problem.
+		 *
+		 * In more detail, if an Append or MergeAppend was elided, then a
+		 * partitionwise join was chosen and only a single child survived; if
+		 * a SubqueryScan was elided, the subquery was planned without
+		 * flattening it into the parent.
+		 */
+		within_join_problem = false;
+		join_unroller = NULL;
+	}
+
+	/*
+	 * If this is a Gather or Gather Merge node, directly add it to the list
+	 * of currently-active query features. We must do this after handling
+	 * elided nodes, since the Gather or Gather Merge node occurs logically
+	 * beneath any associated elided nodes.
+	 *
+	 * Exception: We disregard any single_copy Gather nodes. These are created
+	 * by debug_parallel_query, and having them affect the plan advice is
+	 * counterproductive, as the result will be to advise the use of a real
+	 * Gather node, rather than a single copy one.
+	 */
+	if (IsA(plan, Gather) && !((Gather *) plan)->single_copy)
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER, plan));
+		beneath_any_gather = true;
+	}
+	else if (IsA(plan, GatherMerge))
+	{
+		active_query_features =
+			lappend(list_copy(active_query_features),
+					pgpa_add_feature(walker, PGPAQF_GATHER_MERGE, plan));
+		beneath_any_gather = true;
+	}
+
+	/*
+	 * If we're within a join problem, the join unroller is responsible for
+	 * building any required scan for this node. If not, we do it here.
+	 */
+	if (!within_join_problem)
+		(void) pgpa_build_scan(walker, plan, NULL, beneath_any_gather, false);
+
+	/*
+	 * If this join needs to be unrolled but there's no join unroller already
+	 * available, create one.
+	 */
+	if (join_unroller == NULL && pgpa_is_join(plan))
+	{
+		join_unroller = pgpa_create_join_unroller();
+		join_unroller_toplevel = true;
+		within_join_problem = true;
+	}
+
+	/*
+	 * If this join is to be unrolled, pgpa_unroll_join() will return the join
+	 * unroller object that should be passed down when we recurse into the
+	 * outer and inner sides of the plan.
+	 */
+	if (join_unroller != NULL)
+		pgpa_unroll_join(walker, plan, beneath_any_gather, join_unroller,
+						 &outer_join_unroller, &inner_join_unroller);
+
+	/* Add RTIs from the plan node to all active query features. */
+	pgpa_qf_add_plan_rtis(active_query_features, plan, walker->pstmt->rtable);
+
+	/*
+	 * Recurse into the outer and inner subtrees.
+	 *
+	 * As an exception, if this is a ForeignScan, don't recurse. postgres_fdw
+	 * sometimes stores an EPQ recheck plan in plan->lefttree, but that's going
+	 * to mention the same set of relations as the ForeignScan itself, and we
+	 * have no way to emit advice targeting the EPQ case vs. the non-EPQ case.
+	 * Moreover, it's not entirely clear what other FDWs might do with the
+	 * left and right subtrees. Maybe some better handling is needed here, but
+	 * for now, we just punt.
+	 */
+	if (!IsA(plan, ForeignScan))
+	{
+		if (plan->lefttree != NULL)
+			pgpa_walk_recursively(walker, plan->lefttree, within_join_problem,
+								  outer_join_unroller, active_query_features,
+								  beneath_any_gather);
+		if (plan->righttree != NULL)
+			pgpa_walk_recursively(walker, plan->righttree, within_join_problem,
+								  inner_join_unroller, active_query_features,
+								  beneath_any_gather);
+	}
+
+	/*
+	 * If we created a join unroller up above, then it's also our join to use
+	 * it to build the final pgpa_unrolled_join, and to destroy the object.
+	 */
+	if (join_unroller_toplevel)
+	{
+		pgpa_unrolled_join *ujoin;
+
+		ujoin = pgpa_build_unrolled_join(walker, join_unroller);
+		walker->toplevel_unrolled_joins =
+			lappend(walker->toplevel_unrolled_joins, ujoin);
+		pgpa_destroy_join_unroller(join_unroller);
+		(void) pgpa_process_unrolled_join(walker, ujoin);
+	}
+
+	/*
+	 * Some plan types can have additional children. Nodes like Append that
+	 * can have any number of children store them in a List; a SubqueryScan
+	 * just has a field for a single additional Plan.
+	 */
+	switch (nodeTag(plan))
+	{
+		case T_Append:
+			{
+				Append	   *aplan = (Append *) plan;
+
+				extraplans = aplan->appendplans;
+			}
+			break;
+		case T_MergeAppend:
+			{
+				MergeAppend *maplan = (MergeAppend *) plan;
+
+				extraplans = maplan->mergeplans;
+			}
+			break;
+		case T_BitmapAnd:
+			extraplans = ((BitmapAnd *) plan)->bitmapplans;
+			break;
+		case T_BitmapOr:
+			extraplans = ((BitmapOr *) plan)->bitmapplans;
+			break;
+		case T_SubqueryScan:
+
+			/*
+			 * We don't pass down active_query_features across here, because
+			 * those are specific to a subquery level.
+			 */
+			pgpa_walk_recursively(walker, ((SubqueryScan *) plan)->subplan,
+								  0, NULL, NIL, beneath_any_gather);
+			break;
+		case T_CustomScan:
+			extraplans = ((CustomScan *) plan)->custom_plans;
+			break;
+		default:
+			break;
+	}
+
+	/* If we found a list of extra children, iterate over it. */
+	foreach(lc, extraplans)
+	{
+		Plan	   *subplan = lfirst(lc);
+
+		pgpa_walk_recursively(walker, subplan, false, NULL, NIL,
+							  beneath_any_gather);
+	}
+}
+
+/*
+ * Perform final processing of a newly-constructed pgpa_unrolled_join. This
+ * only needs to be called for toplevel pgpa_unrolled_join objects, since it
+ * recurses to sub-joins as needed.
+ *
+ * Our goal is to add the set of inner relids to the relevant join_strategies
+ * list, and to do the same for any sub-joins. To that end, the return value
+ * is the set of relids found beneath the join, but it is expected that
+ * the toplevel caller will ignore this.
+ */
+static Bitmapset *
+pgpa_process_unrolled_join(pgpa_plan_walker_context *walker,
+						   pgpa_unrolled_join *ujoin)
+{
+	Bitmapset  *all_relids = bms_copy(ujoin->outer.scan->relids);
+
+	/* If this fails, we didn't unroll properly. */
+	Assert(ujoin->outer.unrolled_join == NULL);
+
+	for (int k = 0; k < ujoin->ninner; ++k)
+	{
+		pgpa_join_member *member = &ujoin->inner[k];
+		Bitmapset  *relids;
+
+		if (member->unrolled_join != NULL)
+			relids = pgpa_process_unrolled_join(walker,
+												member->unrolled_join);
+		else
+		{
+			Assert(member->scan != NULL);
+			relids = member->scan->relids;
+		}
+		walker->join_strategies[ujoin->strategy[k]] =
+			lappend(walker->join_strategies[ujoin->strategy[k]], relids);
+		all_relids = bms_add_members(all_relids, relids);
+	}
+
+	return all_relids;
+}
+
+/*
+ * Arrange for the given plan node to be treated as a query feature when the
+ * tree walk reaches it.
+ *
+ * Make sure to only use this for nodes that the tree walk can't have reached
+ * yet!
+ */
+void
+pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+						pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = pgpa_add_feature(walker, type, plan);
+
+	walker->future_query_features =
+		lappend(walker->future_query_features, qf);
+}
+
+/*
+ * Return the last of any elided nodes associated with this plan node ID.
+ *
+ * The last elided node is the one that would have been uppermost in the plan
+ * tree had it not been removed during setrefs processing.
+ */
+ElidedNode *
+pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan)
+{
+	ElidedNode *elided_node = NULL;
+
+	foreach_node(ElidedNode, n, pstmt->elidedNodes)
+	{
+		if (n->plan_node_id == plan->plan_node_id)
+			elided_node = n;
+	}
+
+	return elided_node;
+}
+
+/*
+ * Certain plan nodes can refer to a set of RTIs. Extract and return the set.
+ */
+Bitmapset *
+pgpa_relids(Plan *plan)
+{
+	if (IsA(plan, Result))
+		return ((Result *) plan)->relids;
+	else if (IsA(plan, ForeignScan))
+		return ((ForeignScan *) plan)->fs_relids;
+	else if (IsA(plan, Append))
+		return ((Append *) plan)->apprelids;
+	else if (IsA(plan, MergeAppend))
+		return ((MergeAppend *) plan)->apprelids;
+
+	return NULL;
+}
+
+/*
+ * Extract the scanned RTI from a plan node.
+ *
+ * Returns 0 if there isn't one.
+ */
+Index
+pgpa_scanrelid(Plan *plan)
+{
+	switch (nodeTag(plan))
+	{
+		case T_SeqScan:
+		case T_SampleScan:
+		case T_BitmapHeapScan:
+		case T_TidScan:
+		case T_TidRangeScan:
+		case T_SubqueryScan:
+		case T_FunctionScan:
+		case T_TableFuncScan:
+		case T_ValuesScan:
+		case T_CteScan:
+		case T_NamedTuplestoreScan:
+		case T_WorkTableScan:
+		case T_ForeignScan:
+		case T_CustomScan:
+		case T_IndexScan:
+		case T_IndexOnlyScan:
+			return ((Scan *) plan)->scanrelid;
+		default:
+			return 0;
+	}
+}
+
+/*
+ * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
+ */
+Bitmapset *
+pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable)
+{
+	int			rti = -1;
+	Bitmapset  *result = NULL;
+
+	while ((rti = bms_next_member(relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+		if (rte->rtekind != RTE_JOIN)
+			result = bms_add_member(result, rti);
+	}
+
+	return result;
+}
+
+/*
+ * Create a pgpa_query_feature and add it to the list of all query features
+ * for this plan.
+ */
+static pgpa_query_feature *
+pgpa_add_feature(pgpa_plan_walker_context *walker,
+				 pgpa_qf_type type, Plan *plan)
+{
+	pgpa_query_feature *qf = palloc0_object(pgpa_query_feature);
+
+	qf->type = type;
+	qf->plan = plan;
+
+	walker->query_features[qf->type] =
+		lappend(walker->query_features[qf->type], qf);
+
+	return qf;
+}
+
+/*
+ * Add a single RTI to each active query feature.
+ */
+static void
+pgpa_qf_add_rti(List *active_query_features, Index rti)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_member(qf->relids, rti);
+	}
+}
+
+/*
+ * Add a set of RTIs to each active query feature.
+ */
+static void
+pgpa_qf_add_rtis(List *active_query_features, Bitmapset *relids)
+{
+	foreach_ptr(pgpa_query_feature, qf, active_query_features)
+	{
+		qf->relids = bms_add_members(qf->relids, relids);
+	}
+}
+
+/*
+ * Add RTIs directly contained in a plan node to each active query feature,
+ * but filter out any join RTIs, since advice doesn't mention those.
+ */
+static void
+pgpa_qf_add_plan_rtis(List *active_query_features, Plan *plan, List *rtable)
+{
+	Bitmapset  *relids;
+	Index		rti;
+
+	if ((relids = pgpa_relids(plan)) != NULL)
+	{
+		relids = pgpa_filter_out_join_relids(relids, rtable);
+		pgpa_qf_add_rtis(active_query_features, relids);
+	}
+	else if ((rti = pgpa_scanrelid(plan)) != 0)
+		pgpa_qf_add_rti(active_query_features, rti);
+}
+
+/*
+ * If we generated plan advice using the provided walker object and array
+ * of identifiers, would we generate the specified tag/target combination?
+ *
+ * If yes, the plan conforms to the advice; if no, it does not. Note that
+ * we have no way of knowing whether the planner was forced to emit a plan
+ * that conformed to the advice or just happened to do so.
+ */
+bool
+pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+						 pgpa_identifier *rt_identifiers,
+						 pgpa_advice_tag_type tag,
+						 pgpa_advice_target *target)
+{
+	Index		rtable_length = list_length(walker->pstmt->rtable);
+	Bitmapset  *relids = NULL;
+
+	if (tag == PGPA_TAG_JOIN_ORDER)
+	{
+		foreach_ptr(pgpa_unrolled_join, ujoin, walker->toplevel_unrolled_joins)
+		{
+			if (pgpa_walker_join_order_matches(ujoin, rtable_length,
+											   rt_identifiers, target, true))
+				return true;
+		}
+
+		return false;
+	}
+
+	if (target->ttype == PGPA_TARGET_IDENTIFIER)
+	{
+		Index		rti;
+
+		rti = pgpa_compute_rti_from_identifier(rtable_length, rt_identifiers,
+											   &target->rid);
+		if (rti == 0)
+			return false;
+		relids = bms_make_singleton(rti);
+	}
+	else
+	{
+		Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+		foreach_ptr(pgpa_advice_target, child_target, target->children)
+		{
+			Index		rti;
+
+			Assert(child_target->ttype == PGPA_TARGET_IDENTIFIER);
+			rti = pgpa_compute_rti_from_identifier(rtable_length,
+												   rt_identifiers,
+												   &child_target->rid);
+			if (rti == 0)
+				return false;
+			relids = bms_add_member(relids, rti);
+		}
+	}
+
+	switch (tag)
+	{
+		case PGPA_TAG_JOIN_ORDER:
+			/* should have been handled above */
+			pg_unreachable();
+			break;
+		case PGPA_TAG_BITMAP_HEAP_SCAN:
+			return pgpa_walker_find_scan(walker,
+										 PGPA_SCAN_BITMAP_HEAP,
+										 relids) != NULL;
+		case PGPA_TAG_FOREIGN_JOIN:
+			return pgpa_walker_find_scan(walker,
+										 PGPA_SCAN_FOREIGN,
+										 relids) != NULL;
+		case PGPA_TAG_INDEX_ONLY_SCAN:
+			{
+				pgpa_scan  *scan;
+
+				scan = pgpa_walker_find_scan(walker, PGPA_SCAN_INDEX_ONLY,
+											 relids);
+				if (scan == NULL)
+					return false;
+
+				return pgpa_walker_index_target_matches_plan(target->itarget, scan->plan);
+			}
+		case PGPA_TAG_INDEX_SCAN:
+			{
+				pgpa_scan  *scan;
+
+				scan = pgpa_walker_find_scan(walker, PGPA_SCAN_INDEX,
+											 relids);
+				if (scan == NULL)
+					return false;
+
+				return pgpa_walker_index_target_matches_plan(target->itarget, scan->plan);
+			}
+		case PGPA_TAG_PARTITIONWISE:
+			return pgpa_walker_find_scan(walker,
+										 PGPA_SCAN_PARTITIONWISE,
+										 relids) != NULL;
+		case PGPA_TAG_SEQ_SCAN:
+			return pgpa_walker_find_scan(walker,
+										 PGPA_SCAN_SEQ,
+										 relids) != NULL;
+		case PGPA_TAG_TID_SCAN:
+			return pgpa_walker_find_scan(walker,
+										 PGPA_SCAN_TID,
+										 relids) != NULL;
+		case PGPA_TAG_GATHER:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER,
+												relids);
+		case PGPA_TAG_GATHER_MERGE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_GATHER_MERGE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_NON_UNIQUE,
+												relids);
+		case PGPA_TAG_SEMIJOIN_UNIQUE:
+			return pgpa_walker_contains_feature(walker,
+												PGPAQF_SEMIJOIN_UNIQUE,
+												relids);
+		case PGPA_TAG_HASH_JOIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_HASH_JOIN,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_MERGE_JOIN_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_MERGE_JOIN_PLAIN,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MATERIALIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_MEMOIZE,
+											 relids);
+		case PGPA_TAG_NESTED_LOOP_PLAIN:
+			return pgpa_walker_contains_join(walker,
+											 JSTRAT_NESTED_LOOP_PLAIN,
+											 relids);
+		case PGPA_TAG_NO_GATHER:
+			return pgpa_walker_contains_no_gather(walker, relids);
+	}
+
+	/* should not get here */
+	return false;
+}
+
+/*
+ * Does the index target match the Plan?
+ *
+ * Should only be called when we know that itarget mandates an Index Scan or
+ * Index Only Scan and this corresponds to the type of Plan. Here, our job is
+ * just to check whether it's the same index.
+ */
+static bool
+pgpa_walker_index_target_matches_plan(pgpa_index_target *itarget, Plan *plan)
+{
+	Oid			indexoid = InvalidOid;
+
+	/* Retrieve the index OID from the plan. */
+	if (IsA(plan, IndexScan))
+		indexoid = ((IndexScan *) plan)->indexid;
+	else if (IsA(plan, IndexOnlyScan))
+		indexoid = ((IndexOnlyScan *) plan)->indexid;
+	else
+		elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+
+	/* Check whether schema name matches, if specified in index target. */
+	if (itarget->indnamespace != NULL)
+	{
+		Oid			nspoid = get_rel_namespace(indexoid);
+		char	   *relnamespace = get_namespace_name_or_temp(nspoid);
+
+		if (strcmp(itarget->indnamespace, relnamespace) != 0)
+			return false;
+	}
+
+	/* Check whether relation name matches. */
+	return (strcmp(itarget->indname, get_rel_name(indexoid)) == 0);
+}
+
+/*
+ * Does an unrolled join match the join order specified by an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches(pgpa_unrolled_join *ujoin,
+							   Index rtable_length,
+							   pgpa_identifier *rt_identifiers,
+							   pgpa_advice_target *target,
+							   bool toplevel)
+{
+	int			nchildren = list_length(target->children);
+
+	Assert(target->ttype == PGPA_TARGET_ORDERED_LIST);
+
+	/* At toplevel, we allow a prefix match. */
+	if (toplevel)
+	{
+		if (nchildren > ujoin->ninner + 1)
+			return false;
+	}
+	else
+	{
+		if (nchildren != ujoin->ninner + 1)
+			return false;
+	}
+
+	/* Outermost rel must match. */
+	if (!pgpa_walker_join_order_matches_member(&ujoin->outer,
+											   rtable_length,
+											   rt_identifiers,
+											   linitial(target->children)))
+		return false;
+
+	/* Each inner rel must match. */
+	for (int n = 0; n < nchildren - 1; ++n)
+	{
+		pgpa_advice_target *child_target = list_nth(target->children, n + 1);
+
+		if (!pgpa_walker_join_order_matches_member(&ujoin->inner[n],
+												   rtable_length,
+												   rt_identifiers,
+												   child_target))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Does one member of an unrolled join match an advice target?
+ */
+static bool
+pgpa_walker_join_order_matches_member(pgpa_join_member *member,
+									  Index rtable_length,
+									  pgpa_identifier *rt_identifiers,
+									  pgpa_advice_target *target)
+{
+	Bitmapset  *relids = NULL;
+
+	if (member->unrolled_join != NULL)
+	{
+		if (target->ttype != PGPA_TARGET_ORDERED_LIST)
+			return false;
+		return pgpa_walker_join_order_matches(member->unrolled_join,
+											  rtable_length,
+											  rt_identifiers,
+											  target,
+											  false);
+	}
+
+	Assert(member->scan != NULL);
+	switch (target->ttype)
+	{
+		case PGPA_TARGET_ORDERED_LIST:
+			/* Could only match an unrolled join */
+			return false;
+
+		case PGPA_TARGET_UNORDERED_LIST:
+			{
+				foreach_ptr(pgpa_advice_target, child_target, target->children)
+				{
+					Index		rti;
+
+					rti = pgpa_compute_rti_from_identifier(rtable_length,
+														   rt_identifiers,
+														   &child_target->rid);
+					if (rti == 0)
+						return false;
+					relids = bms_add_member(relids, rti);
+				}
+				break;
+			}
+
+		case PGPA_TARGET_IDENTIFIER:
+			{
+				Index		rti;
+
+				rti = pgpa_compute_rti_from_identifier(rtable_length,
+													   rt_identifiers,
+													   &target->rid);
+				if (rti == 0)
+					return false;
+				relids = bms_make_singleton(rti);
+				break;
+			}
+	}
+
+	return bms_equal(member->scan->relids, relids);
+}
+
+/*
+ * Find the scan where the walker says that the given scan strategy should be
+ * used for the given relid set, if one exists.
+ *
+ * Returns the pgpa_scan object, or NULL if none was found.
+ */
+static pgpa_scan *
+pgpa_walker_find_scan(pgpa_plan_walker_context *walker,
+					  pgpa_scan_strategy strategy,
+					  Bitmapset *relids)
+{
+	List	   *scans = walker->scans[strategy];
+
+	foreach_ptr(pgpa_scan, scan, scans)
+	{
+		if (bms_equal(scan->relids, relids))
+			return scan;
+	}
+
+	return NULL;
+}
+
+/*
+ * Does this walker say that the given query feature applies to the given
+ * relid set?
+ */
+static bool
+pgpa_walker_contains_feature(pgpa_plan_walker_context *walker,
+							 pgpa_qf_type type,
+							 Bitmapset *relids)
+{
+	List	   *query_features = walker->query_features[type];
+
+	foreach_ptr(pgpa_query_feature, qf, query_features)
+	{
+		if (bms_equal(qf->relids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given join strategy should be used for the
+ * given relid set?
+ */
+static bool
+pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
+						  pgpa_join_strategy strategy,
+						  Bitmapset *relids)
+{
+	List	   *join_strategies = walker->join_strategies[strategy];
+
+	foreach_ptr(Bitmapset, jsrelids, join_strategies)
+	{
+		if (bms_equal(jsrelids, relids))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Does the walker say that the given relids should be marked as NO_GATHER?
+ */
+static bool
+pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
+							   Bitmapset *relids)
+{
+	return bms_is_subset(relids, walker->no_gather_scans);
+}
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
new file mode 100644
index 00000000000..4890d554dd3
--- /dev/null
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -0,0 +1,141 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_walker.h
+ *	  Main entrypoints for analyzing a plan to generate an advice string
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_plan_advice/pgpa_walker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_WALKER_H
+#define PGPA_WALKER_H
+
+#include "pgpa_ast.h"
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+
+/*
+ * When generating advice, we should emit either SEMIJOIN_UNIQUE advice or
+ * SEMIJOIN_NON_UNIQUE advice for each semijoin depending on whether we chose
+ * to implement it as a semijoin or whether we instead chose to make the
+ * nullable side unique and then perform an inner join. When the make-unique
+ * strategy is not chosen, it's not easy to tell from the final plan tree
+ * whether it was considered. That's awkward, because we don't want to emit
+ * useless SEMIJOIN_NON_UNIQUE advice when there was no decision to be made.
+ *
+ * To avoid that, during planning, we create a pgpa_sj_unique_rel for each
+ * relation that we considered making unique for purposes of semijoin planning.
+ */
+typedef struct pgpa_sj_unique_rel
+{
+	char	   *plan_name;
+	Bitmapset  *relids;
+} pgpa_sj_unique_rel;
+
+/*
+ * We use the term "query feature" to refer to plan nodes that are interesting
+ * in the following way: to generate advice, we'll need to know the set of
+ * same-subquery, non-join RTIs occurring at or below that plan node, without
+ * admixture of parent and child RTIs.
+ *
+ * For example, Gather nodes, designated by PGPAQF_GATHER, and Gather Merge
+ * nodes, designated by PGPAQF_GATHER_MERGE, are query features, because we'll
+ * want to admit some kind of advice that describes the portion of the plan
+ * tree that appears beneath those nodes.
+ *
+ * Each semijoin can be implemented either by directly performing a semijoin,
+ * or by making one side unique and then performing a normal join. Either way,
+ * we use a query feature to notice what decision was made, so that we can
+ * describe it by enumerating the RTIs on that side of the join.
+ *
+ * To elaborate on the "no admixture of parent and child RTIs" rule, in all of
+ * these cases, if the entirety of an inheritance hierarchy appears beneath
+ * the query feature, we only want to name the parent table. But it's also
+ * possible to have cases where we must name child tables. This is particularly
+ * likely to happen when partitionwise join is in use, but could happen for
+ * Gather or Gather Merge even without that, if one of those appears below
+ * an Append or MergeAppend node for a single table.
+ */
+typedef enum pgpa_qf_type
+{
+	PGPAQF_GATHER,
+	PGPAQF_GATHER_MERGE,
+	PGPAQF_SEMIJOIN_NON_UNIQUE,
+	PGPAQF_SEMIJOIN_UNIQUE
+	/* update NUM_PGPA_QF_TYPES if you add anything here */
+} pgpa_qf_type;
+
+#define NUM_PGPA_QF_TYPES ((int) PGPAQF_SEMIJOIN_UNIQUE + 1)
+
+/*
+ * For each query feature, we keep track of the feature type and the set of
+ * relids that we found underneath the relevant plan node. See the comments
+ * on pgpa_qf_type, above, for additional details.
+ */
+typedef struct pgpa_query_feature
+{
+	pgpa_qf_type type;
+	Plan	   *plan;
+	Bitmapset  *relids;
+} pgpa_query_feature;
+
+/*
+ * Context object for plan tree walk.
+ *
+ * pstmt is the PlannedStmt we're studying.
+ *
+ * scans is an array of lists of pgpa_scan objects. The array is indexed by
+ * the scan's pgpa_scan_strategy.
+ *
+ * no_gather_scans is the set of scan RTIs that do not appear beneath any
+ * Gather or Gather Merge node.
+ *
+ * toplevel_unrolled_joins is a list of all pgpa_unrolled_join objects that
+ * are not a child of some other pgpa_unrolled_join.
+ *
+ * join_strategy is an array of lists of Bitmapset objects. Each Bitmapset
+ * is the set of relids that appears on the inner side of some join (excluding
+ * RTIs from partition children and subqueries). The array is indexed by
+ * pgpa_join_strategy.
+ *
+ * query_features is an array lists of pgpa_query_feature objects, indexed
+ * by pgpa_qf_type.
+ *
+ * future_query_features is only used during the plan tree walk and should
+ * be empty when the tree walk concludes. It is a list of pgpa_query_feature
+ * objects for Plan nodes that the plan tree walk has not yet encountered;
+ * when encountered, they will be moved to the list of active query features
+ * that is propagated via the call stack.
+ */
+typedef struct pgpa_plan_walker_context
+{
+	PlannedStmt *pstmt;
+	List	   *scans[NUM_PGPA_SCAN_STRATEGY];
+	Bitmapset  *no_gather_scans;
+	List	   *toplevel_unrolled_joins;
+	List	   *join_strategies[NUM_PGPA_JOIN_STRATEGY];
+	List	   *query_features[NUM_PGPA_QF_TYPES];
+	List	   *future_query_features;
+} pgpa_plan_walker_context;
+
+extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
+							 PlannedStmt *pstmt,
+							 List *sj_unique_rels);
+
+extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
+									pgpa_qf_type type,
+									Plan *plan);
+
+extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
+extern Bitmapset *pgpa_relids(Plan *plan);
+extern Index pgpa_scanrelid(Plan *plan);
+extern Bitmapset *pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable);
+
+extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
+									 pgpa_identifier *rt_identifiers,
+									 pgpa_advice_tag_type tag,
+									 pgpa_advice_target *target);
+
+#endif
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
new file mode 100644
index 00000000000..776666bf196
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -0,0 +1,86 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+
+CREATE TABLE gt_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+
+CREATE TABLE gt_fact (
+	id int not null,
+	dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((d d/d.d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
+
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Test conflicting advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather((f d)) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_order.sql b/contrib/pg_plan_advice/sql/join_order.sql
new file mode 100644
index 00000000000..88d90de9cc6
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_order.sql
@@ -0,0 +1,145 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 53) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+
+CREATE TABLE jo_fact (
+	id int primary key,
+	dim1_id integer not null references jo_dim1 (id),
+	dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f {d1 d2})';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- Force a join order by mentioning just a prefix of the join list.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- jo_fact is not partitioned, but let's try pretending that it is and
+-- verifying that the advice does not apply.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f/d1 d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SET LOCAL pg_plan_advice.advice = 'join_order(f/d1 (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+COMMIT;
+
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
+
+-- Two incompatible join orders should conflict. In the second case,
+-- the conflict is implicit: if d1 is on the inner side of a join of any
+-- type, it cannot also be the driving table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f) join_order(d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+SET LOCAL pg_plan_advice.advice = 'join_order(d1) hash_join(d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+	INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+	ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/join_strategy.sql b/contrib/pg_plan_advice/sql/join_strategy.sql
new file mode 100644
index 00000000000..edd5c4c0e14
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/join_strategy.sql
@@ -0,0 +1,84 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE join_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+
+CREATE TABLE join_fact (
+	id int primary key,
+	dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+	SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
+
+-- Non-working cases. We can't force a foreign join between these tables,
+-- because they aren't foreign tables. We also can't use two different
+-- strategies on the same table, nor can we put both tables on the inner
+-- side of the same join.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f) NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+	SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/partitionwise.sql b/contrib/pg_plan_advice/sql/partitionwise.sql
new file mode 100644
index 00000000000..c51456dbbb5
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/partitionwise.sql
@@ -0,0 +1,99 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+	SELECT g, 'some other text ' || g, (g % 5) + 1
+	  FROM generate_series(1,3000,2) g;
+VACUUM ANALYZE pt2;
+
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+	PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+	SELECT g, 'a third random text ' || g, (g % 7) + 1
+	  FROM generate_series(1,3000,3) g;
+VACUUM ANALYZE pt3;
+
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+	PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+    FOR VALUES FROM (1) to (1501)
+	WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+    FOR VALUES FROM (1501) to (3001)
+	WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+	SELECT g, 'yet another text ' || g, (g % 2) + 1
+	  FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Test conflicting advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) (pt1 pt3))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
+
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+COMMIT;
+
+-- Force join order for a particular branch of the partitionwise join with
+-- and without mentioning the schema name.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'JOIN_ORDER(pt3/public.pt3a pt2/public.pt2a pt1/public.pt1a)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+SET LOCAL pg_plan_advice.advice = 'JOIN_ORDER(pt3/pt3a pt2/pt2a pt1/pt1a)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+   AND val1 = 1 AND val2 = 1 AND val3 = 1;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/prepared.sql b/contrib/pg_plan_advice/sql/prepared.sql
new file mode 100644
index 00000000000..3ec30eedee5
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/prepared.sql
@@ -0,0 +1,37 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE ptab (id integer, val text) WITH (autovacuum_enabled = false);
+
+SET pg_plan_advice.always_store_advice_details = false;
+
+-- Not prepared, so advice should be generated.
+EXPLAIN (COSTS OFF, PLAN_ADVICE) 
+SELECT * FROM ptab;
+
+-- Prepared, so advice should not be generated.
+PREPARE pt1 AS SELECT * FROM ptab;
+EXPLAIN (COSTS OFF, PLAN_ADVICE) EXECUTE pt1;
+
+SET pg_plan_advice.always_store_advice_details = true;
+
+-- Prepared, but always_store_advice_details = true, so should show advice.
+PREPARE pt2 AS SELECT * FROM ptab;
+EXPLAIN (COSTS OFF, PLAN_ADVICE) EXECUTE pt2;
+
+-- Not prepared, so feedback should be generated.
+SET pg_plan_advice.always_store_advice_details = false;
+SET pg_plan_advice.advice = 'SEQ_SCAN(ptab)';
+EXPLAIN (COSTS OFF) 
+SELECT * FROM ptab;
+
+-- Prepared, so advice should not be generated.
+PREPARE pt3 AS SELECT * FROM ptab;
+EXPLAIN (COSTS OFF) EXECUTE pt1;
+
+SET pg_plan_advice.always_store_advice_details = true;
+
+-- Prepared, but always_store_advice_details = true, so should show feedback.
+PREPARE pt4 AS SELECT * FROM ptab;
+EXPLAIN (COSTS OFF, PLAN_ADVICE) EXECUTE pt2;
+
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
new file mode 100644
index 00000000000..4fc494c7d8e
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -0,0 +1,195 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+
+CREATE TABLE scan_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+	SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+COMMIT;
+
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+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 > 0;
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table)';
+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).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+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;
+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.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE b > 'some text 8';
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+	WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+COMMIT;
+
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+COMMIT;
+
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+    LEFT JOIN scan_table s ON g = s.a;
+COMMIT;
+
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/semijoin.sql b/contrib/pg_plan_advice/sql/semijoin.sql
new file mode 100644
index 00000000000..5a4ae52d1d9
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/semijoin.sql
@@ -0,0 +1,118 @@
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE sj_wide (
+	id integer primary key,
+	val1 integer,
+	padding text storage plain
+) WITH (autovacuum_enabled = false);
+INSERT INTO sj_wide
+	SELECT g, g%10+1, repeat(' ', 300) FROM generate_series(1, 1000) g;
+CREATE INDEX ON sj_wide (val1);
+VACUUM ANALYZE sj_wide;
+
+CREATE TABLE sj_narrow (
+	id integer primary key,
+	val1 integer
+) WITH (autovacuum_enabled = false);
+INSERT INTO sj_narrow
+	SELECT g, g%10+1 FROM generate_series(1, 1000) g;
+CREATE INDEX ON sj_narrow (val1);
+VACUUM ANALYZE sj_narrow;
+
+-- We expect this to make the VALUES list unique and use index lookups to
+-- find the rows in sj_wide, so as to avoid a full scan of sj_wide.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_wide
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+
+-- If we ask for a unique semijoin, we should get the same plan as with
+-- no advice. If we ask for a non-unique semijoin, we should see a Semi
+-- Join operation in the plan tree.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique("*VALUES*")';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_wide
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique("*VALUES*")';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_wide
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+COMMIT;
+
+-- Because this table is narrower than the previous one, a sequential scan
+-- is less expensive, and we choose a straightforward Semi Join plan by
+-- default. (Note that this is also very sensitive to the length of the IN
+-- list, which affects how many index lookups the alternative plan will need.)
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_narrow
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+
+-- Here, we expect advising a unique semijoin to swith to the same plan that
+-- we got with sj_wide, and advising a non-unique semijoin should not change
+-- the plan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique("*VALUES*")';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_narrow
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique("*VALUES*")';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM sj_narrow
+	WHERE (id, val1) IN (VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5));
+COMMIT;
+
+-- In the above example, we made the outer side of the join unique, but here,
+-- we should make the inner side unique.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+
+-- We should be able to force a plan with or without the make-unique strategy,
+-- with either side as the driving table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(sj_narrow) join_order(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique(sj_narrow) join_order(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+COMMIT;
+
+-- However, mentioning the wrong side of the join should result in an advice
+-- failure.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(g)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+SET LOCAL pg_plan_advice.advice = 'semijoin_non_unique(g)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+COMMIT;
+
+-- Test conflicting advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(sj_narrow) semijoin_non_unique(sj_narrow)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g
+    WHERE g in (select val1 from sj_narrow);
+COMMIT;
+
+-- Try applying SEMIJOIN_UNIQUE() to a non-semijoin.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'semijoin_unique(g)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM generate_series(1,1000) g, sj_narrow s WHERE g = s.val1;
+COMMIT;
diff --git a/contrib/pg_plan_advice/sql/syntax.sql b/contrib/pg_plan_advice/sql/syntax.sql
new file mode 100644
index 00000000000..56a5d54e2b5
--- /dev/null
+++ b/contrib/pg_plan_advice/sql/syntax.sql
@@ -0,0 +1,68 @@
+LOAD 'pg_plan_advice';
+
+-- An empty string is allowed. Empty target lists are allowed for most advice
+-- tags, but not for JOIN_ORDER. "Supplied Plan Advice" should be omitted in
+-- text format when there is no actual advice, but not in non-text format.
+SET pg_plan_advice.advice = '';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = 'NESTED_LOOP_PLAIN()';
+EXPLAIN (COSTS OFF, FORMAT JSON) SELECT 1;
+SET pg_plan_advice.advice = 'JOIN_ORDER()';
+
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = '  SEQ_SCAN ( x / y . z )  ';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+EXPLAIN (COSTS OFF) SELECT 1;
+
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+SET pg_plan_advice.advice = 'SEQ_SCAN("")';
+SET pg_plan_advice.advice = 'SEQ_SCAN("a"';
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+SET pg_plan_advice.advice = '()';
+SET pg_plan_advice.advice = '123';
+
+-- Tags like SEQ_SCAN and NO_GATHER don't allow sublists at all; other tags,
+-- except for JOIN_ORDER, allow at most one level of sublist. Hence, these
+-- examples should error out.
+SET pg_plan_advice.advice = 'SEQ_SCAN((x))';
+SET pg_plan_advice.advice = 'GATHER(((x)))';
+
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+EXPLAIN (COSTS OFF) SELECT 1;
+
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+EXPLAIN (COSTS OFF) SELECT 1;
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index 24b706b29ad..bdd4865f53f 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -156,6 +156,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgfreespacemap;
  &pglogicalinspect;
  &pgoverexplain;
+ &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
  &pgstatstatements;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index ac66fcbdb57..d90b4338d2a 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -149,6 +149,7 @@
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
 <!ENTITY pglogicalinspect  SYSTEM "pglogicalinspect.sgml">
 <!ENTITY pgoverexplain   SYSTEM "pgoverexplain.sgml">
+<!ENTITY pgplanadvice    SYSTEM "pgplanadvice.sgml">
 <!ENTITY pgprewarm       SYSTEM "pgprewarm.sgml">
 <!ENTITY pgrowlocks      SYSTEM "pgrowlocks.sgml">
 <!ENTITY pgstatstatements SYSTEM "pgstatstatements.sgml">
diff --git a/doc/src/sgml/pgplanadvice.sgml b/doc/src/sgml/pgplanadvice.sgml
new file mode 100644
index 00000000000..8df8a978ecf
--- /dev/null
+++ b/doc/src/sgml/pgplanadvice.sgml
@@ -0,0 +1,813 @@
+<!-- doc/src/sgml/pgplanadvice.sgml -->
+
+<sect1 id="pgplanadvice" xreflabel="pg_plan_advice">
+ <title>pg_plan_advice &mdash; help the planner get the right plan</title>
+
+ <indexterm zone="pgplanadvice">
+  <primary>pg_plan_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_plan_advice</filename> module allows key planner decisions
+  to be described, reproduced, and altered using a special-purpose "plan
+  advice" mini-language. It is intended to allow stabilization of plan choices
+  that the user believes to be good, as well as experimentation with plans that
+  the planner believes to be non-optimal.
+ </para>
+
+ <para>
+  Note that, since the planner often makes good decisions, overriding its
+  judgment can easily backfire. For example, if the distribution of the
+  underlying data changes, the planner normally has the option to adjust the
+  plan in an attempt to preserve good performance. If the plan advice prevents
+  this, a very poor plan may be chosen. It is important to use plan advice
+  only when the risks of constraining the planner's choices are outweighed by
+  the benefits.
+ </para>
+
+ <sect2 id="pgplanadvice-getting-started">
+  <title>Getting Started</title>
+
+  <para>
+   First, you must arrange to load the <literal>pg_plan_advice</literal>
+   module. You can do this on a system-wide basis by adding
+   <literal>pg_plan_advice</literal> to
+   <xref linkend="guc-shared-preload-libraries"/> and restarting the
+   server, or by adding it to
+   <xref linkend="guc-session-preload-libraries"/> and starting a new session,
+   or by loading it into an individual session using the
+   <link linkend="sql-load"><literal>LOAD</literal></link> command.
+  </para>
+
+  <para>
+   Once the <literal>pg_plan_advice</literal> module is loaded,
+   <link linkend="sql-explain"><literal>EXPLAIN</literal></link> will support
+   a <literal>PLAN_ADVICE</literal> option. You can use this option to see
+   a plan advice string for the chosen plan. For example:
+  </para>
+
+<programlisting>
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+        SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Generated Plan Advice:
+   JOIN_ORDER(f d)
+   HASH_JOIN(d)
+   SEQ_SCAN(f d)
+   NO_GATHER(f d)
+</programlisting>
+
+  <para>
+   In this example, the user has not specified any advice; instead, the
+   planner has been permitted to make whatever decisions it thinks best, and
+   those decisions are memorialized in the form of an advice string.
+   <literal>JOIN_ORDER(f d)</literal> means that <literal>f</literal> should
+   be the driving table, and the first table to which it should be joined is
+   <literal>d</literal>. <literal>HASH_JOIN(d)</literal> means that
+   <literal>d</literal> should appear on the inner side of a hash join.
+   <literal>SEQ_SCAN(f d)</literal> means that both <literal>f</literal>
+   and <literal>d</literal> should be accessed via a sequential scan.
+   <literal>NO_GATHER(f d)</literal> means that neither <literal>f</literal>
+   nor <literal>d</literal> should appear beneath a <literal>Gather</literal>
+   or <literal>Gather Merge</literal> node. For more details on the plan
+   advice mini-language, see the information on
+   <link linkend="pgplanadvice-targets">advice targets</link> and
+   <link linkend="pgplanadvice-tags">advice tags</link>, below.
+  </para>
+
+  <para>
+   Once you have an advice string for a query, you can use it to control how
+   that query is planned. You can do this by setting
+   <literal>pg_plan_advice.advice</literal> to the advice string you've
+   chosen. This can be an advice string that was generated by the system,
+   or one you've written yourself. One good way of creating your own advice
+   string is to take the string generated by the system and pick out just
+   those elements that you wish to enforce. In the example above,
+   <literal>pg_plan_advice</literal> emits advice for the join order, the
+   join method, the scan method, and the use of parallelism, but you might
+   only want to control the join order:
+  </para>
+
+<programlisting>
+SET pg_plan_advice.advice = 'JOIN_ORDER(f d)';
+EXPLAIN (COSTS OFF)
+        SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+             QUERY PLAN
+------------------------------------
+ Hash Join
+   Hash Cond: (f.dim_id = d.id)
+   ->  Seq Scan on join_fact f
+   ->  Hash
+         ->  Seq Scan on join_dim d
+ Supplied Plan Advice:
+   JOIN_ORDER(f d) /* matched */
+</programlisting>
+
+  <para>
+   Since the <literal>PLAN_ADVICE</literal> option to
+   <literal>EXPLAIN</literal> was not specified, no advice string is generated
+   for the plan. However, the supplied plan advice is still shown so that
+   anyone looking at the <literal>EXPLAIN</literal> output knows that the
+   chosen plan was influenced by plan advice. If information about supplied
+   plan advice is not desired, it can be suppressed by configuring
+   <literal>pg_plan_advice.always_explain_supplied_advice = false</literal>.
+   For each piece of supplied advice, the output shows
+   <link linkend="pgplanadvice-feedback">advice feedback</link> indicating
+   whether or not the advice was successfully applied to the query. In this
+   case, the feedback says <literal>/* matched */</literal>, which means that
+   <literal>f</literal> and <literal>d</literal> were found in the query and
+   that the resulting query plan conforms to the specified advice.
+  </para>
+
+ </sect2>
+
+ <sect2 id="pgplanadvice-how-it-works">
+  <title>How It Works</title>
+
+  <para>
+   Plan advice is written imperatively; that is, it specifies what should be
+   done. However, at an implementation level,
+   <literal>pg_plan_advice</literal> works by telling the core planner what
+   should not be done. In other words, it operates by constraining the
+   planner's choices, not by replacing it. Therefore, no matter what advice
+   you provide, you will only ever get a plan that the core planner would have
+   considered for the query in question. If you attempt to force what you
+   believe to be the correct plan by supplying an advice string, and the
+   planner still fails to produce the desired plan, this means that either
+   there is a bug in your advice string, or the plan in question was not
+   considered viable by the core planner. This commonly happens for one of two
+   reasons. First, it might be that the planner believes that the plan you're
+   trying to force would be semantically incorrect - that is, it would produce
+   the wrong results - and for that reason it wasn't considered. Second, it
+   might be that the planner rejected the plan you were hoping to generate on
+   some grounds other than cost. For example, given a very simple query such as
+   <literal>SELECT * FROM some_table</literal>, the query planner will
+   decide that the use of an index is worthless here before it performs any
+   costing calculations. You cannot force it to use an index for this query
+   even if you set <literal>enable_seqscan = false</literal>, and you can't
+   force it to use an index using plan advice, either.
+  </para>
+
+  <para>
+   Specifying plan advice should never cause planner failure. However, if you
+   specify plan advice that asks for something impossible, you may get a plan
+   where some plan nodes are flagged as <literal>Disabled: true</literal> in
+   the <literal>EXPLAIN</literal> output. In some cases, such plans will be
+   basically the same plan you would have gotten with no supplied advice at
+   all, but in other cases, they may be much worse. For example:
+  </para>
+
+<programlisting>
+SET pg_plan_advice.advice = 'JOIN_ORDER(x f d)';
+EXPLAIN (COSTS OFF)
+        SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+                     QUERY PLAN
+----------------------------------------------------
+ Nested Loop
+   Disabled: true
+   ->  Seq Scan on join_fact f
+   ->  Index Scan using join_dim_pkey on join_dim d
+         Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+   JOIN_ORDER(x f d) /* partially matched */
+</programlisting>
+
+  <para>
+   Because neither <literal>f</literal> nor <literal>d</literal> is the
+   first table in the <literal>JOIN_ORDER()</literal> specification, the
+   planner disables all direct joins between the two of them, thinking that
+   the join to <literal>x</literal> should happen first. Since planning isn't
+   allowed to fail, a disabled plan between the two relations is eventually
+   selected anyway, but here it's a <literal>Nested Loop</literal> rather than
+   the <literal>Hash Join</literal> that was chosen in the above example where
+   no advice was specified. There are several different ways that this kind
+   of thing can happen; when it does, the resulting plan is generally worse
+   than if no advice had been specified at all. Therefore, it is a good idea
+   to validate that the advice you specify applies to the query to which it
+   is applied and that the results are as expected.
+  </para>
+
+ </sect2>
+
+ <sect2 id="pgplanadvice-targets">
+  <title>Advice Targets</title>
+
+  <para>
+   An <firstterm>advice target</firstterm> uniquely identifies a particular
+   instance of a particular relation involved in a particular query. In simple
+   cases, such as the examples shown above, the advice target is simply the
+   relation alias. However, a more complex syntax is required when subqueries
+   are used, when tables are partitioned, or when the same relation alias is
+   mentioned more than once in the same subquery (e.g., <literal>(foo JOIN bar
+   ON foo.a = bar.a) x JOIN foo ON x.b = foo.b</literal>). Any combination of
+   these three things can occur simultaneously: a relation could be mentioned
+   more than once, be partitioned, and be used inside of a subquery.
+  </para>
+
+  <para>
+   Because of this, the general syntax for a relation identifier is:
+  </para>
+
+<programlisting>
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+</programlisting>
+
+  <para>
+   All components except for the <literal>alias_name</literal> are optional
+   and are included only when required. When a component is omitted, the
+   preceding punctuation must also be omitted. For the first occurrence of a
+   relation within a given subquery, generated advice will omit the occurrence
+   number, but it is legal to write <literal>#1</literal>, if desired. The
+   partition schema and partition name are included only for children of
+   partitioned tables. In generated advice, <literal>pg_plan_advice</literal>
+   always includes both, but it is legal to omit the schema. The plan name is
+   omitted for the top-level plan, and must be included for any subplan.
+  </para>
+
+  <para>
+   It is not always easy to determine the correct advice target by examining
+   the query. For instance, if the planner pulls up a subquery into the parent
+   query level, everything inside of it becomes part of the parent query level,
+   and uses the parent query's subplan name (or no subplan name, if pulled up
+   to the top level). Furthermore, the correct subquery name is sometimes not
+   obvious. For example, when two queries are joined using an operation such as
+   <literal>UNION</literal> or <literal>INTERSECT</literal>, no name for the
+   subqueries is present in the SQL syntax; instead, a system-generated name is
+   assigned to each branch. The easiest way to discover the proper advice
+   targets is to use <literal>EXPLAIN (PLAN_ADVICE)</literal> and examine the
+   generated advice.
+  </para>
+
+ </sect2>
+
+ <sect2 id="pgplanadvice-tags">
+  <title>Advice Tags</title>
+
+  <para>
+   An <firstterm>advice tag</firstterm> specifies a particular behavior that
+   should be enforced for some portion of the query, such as a particular
+   join order or join method. All advice tags take
+   <link linkend="pgplanadvice-targets">advice targets</link> as arguments,
+   and many allow lists of advice targets, which in some cases can be nested
+   multiple levels deep. Several different classes of advice tags exist,
+   each controlling a different aspect of query planning.
+  </para>
+
+  <sect3 id="pgplanadvice-scan-method">
+  <title>Scan Method Advice</title>
+   <synopsis>
+SEQ_SCAN(<replaceable>target</replaceable> [ ... ])
+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>
+
+   <para>
+    <literal>SEQ_SCAN</literal> specifies that each target should be
+    scanned using a <literal>Seq Scan</literal>. <literal>TID_SCAN</literal>
+    specifies that each target should be scanned using a
+    <literal>TID Scan</literal> or <literal>TID Range Scan</literal>.
+    <literal>BITMAP_HEAP_SCAN</literal> specifies that each target
+    should be scanned using a <literal>Bitmap Heap Scan</literal>.
+   </para>
+
+   <para>
+    <literal>INDEX_SCAN</literal> specifies that each target should
+    be scanned using an <literal>Index Scan</literal> on the given index
+    name. <literal>INDEX_ONLY_SCAN</literal> is similar, but specifies the
+    use of an <literal>Index Only Scan</literal>. In either case, the index
+    name can be, but does not have to be, schema-qualified.
+   </para>
+
+   <para>
+    <literal>FOREIGN_SCAN</literal> specifies that a join between two or
+    more foreign tables should be pushed down to a remote server so
+    that it can be implemented as a single <literal>Foreign Scan</literal>.
+    Specifying <literal>FOREIGN_SCAN</literal> for a single foreign table is
+    neither necessary nor permissible: a <literal>Foreign Scan</literal> will
+    need to be used regardless. If you want to prevent a join from being
+    pushed down, consider using the <literal>JOIN_ORDER</literal> tag for
+    that purpose.
+   </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
+    made, and hence no need for advice. For example, the output of a
+    set-returning function that appears in the <literal>FROM</literal> clause
+    can only ever be scanned using a <literal>Function Scan</literal>, so
+    there is no opportunity for advice to change anything.
+   </para>
+
+  </sect3>
+
+  <sect3 id="pgplanadvice-join-order">
+  <title>Join Order Advice</title>
+   <synopsis>
+JOIN_ORDER(<replaceable>join_order_item</replaceable> [ ... ])
+
+<phrase>where <replaceable>join_order_item</replaceable> is:</phrase>
+
+<replaceable>advice_target</replaceable> |
+( <replaceable>join_order_item</replaceable> [ ... ] ) |
+{ <replaceable>join_order_item</replaceable> [ ... ] }</synopsis>
+
+   <para>
+    When <literal>JOIN_ORDER</literal> is used without any sublists, it
+    specifies an outer-deep join with the first advice target as the driving
+    table, joined to each subsequent advice target in turn in the order
+    specified. For instance, <literal>JOIN_ORDER(a b c)</literal> means that
+    <literal>a</literal> should be the driving table, and that it should be
+    joined first to <literal>b</literal> and then to <literal>c</literal>.
+    If there are more relations in the query than <literal>a</literal>,
+    <literal>b</literal>, and <literal>c</literal>, the rest can be joined
+    afterwards in any manner.
+   </para>
+
+   <para>
+    If a <literal>JOIN_ORDER</literal> list contains a parenthesized sublist,
+    it specifies a non-outer-deep join. The relations in the sublist must first
+    be joined to each other much as if the sublist were a top-level
+    <literal>JOIN_ORDER</literal> list, and the resulting join product must
+    then appear on the inner side of a join at the appropriate point in the
+    join order. For example, <literal>JOIN_ORDER(a (b c) d)</literal> requires
+    a plan of this form:
+   </para>
+
+<programlisting>
+Join
+  ->  Join
+        -> Scan on a
+        -> Join
+             -> Scan on b
+             -> Scan on c
+  ->  Scan on d
+</programlisting>
+
+   <para>
+    If a <literal>JOIN_ORDER</literal> list contains a sublist surrounded by
+    curly braces, this also specifies a non-outer-deep join. However, the join
+    order within the sublist is not constrained. For example, specifying
+    <literal>JOIN_ORDER(a {b c} d)</literal> would allow the scans of
+    <literal>b</literal> and <literal>c</literal> to be swapped in the
+    previous example, which is not allowed when parentheses are used.
+   </para>
+
+   <para>
+    Parenthesized sublists can be arbitrarily nested, but sublists surrounded
+    by curly braces cannot themselves contain sublists.
+   </para>
+
+   <para>
+    Multiple instances of <literal>JOIN_ORDER()</literal> can sometimes be
+    needed in order to fully constrain the join order. This occurs when there
+    are multiple join problems that are optimized separately by the planner.
+    This can happen due to the presence of subqueries, or because there is a
+    partitionwise join. In the latter case, each branch of the partitionwise
+    join can have its own join order, independent of every other branch.
+   </para>
+
+  </sect3>
+
+  <sect3 id="pgplanadvice-join-method">
+  <title>Join Method Advice</title>
+   <synopsis>
+join_method_name(<replaceable>join_method_item</replaceable> [ ... ])
+
+<phrase>where <replaceable>join_method_name</replaceable> is:</phrase>
+
+{ MERGE_JOIN_MATERIALIZE | MERGE_JOIN_PLAIN | NESTED_LOOP_MATERIALIZE | NESTED_LOOP_PLAIN | HASH_JOIN }
+
+<phrase>and <replaceable>join_method_item</replaceable> is:</phrase>
+
+{ <replaceable>advice_target</replaceable> |
+( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
+
+   <para>
+    Join method advice specifies the relation, or set of relations, that should
+    appear on the inner side of a join using the named join method. For
+    example, <literal>HASH_JOIN(a b)</literal> means that each of
+    <literal>a</literal> and <literal>b</literal> should appear on the inner
+    side of a hash join; a conforming plan must contain at least two hash
+    joins, one of which has <literal>a</literal> and nothing else on the
+    inner side, and the other of which has <literal>b</literal> and nothing
+    else on the inner side. On the other hand,
+    <literal>HASH_JOIN((a b))</literal> means that the join product of
+    <literal>a</literal> and <literal>b</literal> should appear together
+    on the inner side of a single hash join.
+   </para>
+
+   <para>
+    Note that join method advice implies a negative join order constraint.
+    Since the named relation or relations must be on the inner side of a join
+    using the specified method, none of them can be the driving table for the
+    entire join problem. Moreover, no relation inside the set should be joined
+    to any relation outside the set until all relations within the set have
+    been joined to each other. For example, if the advice specifies
+    <literal>HASH_JOIN((a b))</literal> and the system begins by joining either
+    of those to some third relation <literal>c</literal>, the resulting
+    plan could never be compliant with the request to put exactly those two
+    relations on the inner side of a hash join. When using both join order
+    advice and join method advice for the same query, it is a good idea to make
+    sure that they do not mandate incompatible join orders.
+   </para>
+
+  </sect3>
+
+  <sect3 id="pgplanadvice-partitionwise">
+  <title>Partitionwise Advice</title>
+   <synopsis>
+PARTITIONWISE(<replaceable>partitionwise_item</replaceable> [ ... ])
+
+<phrase>where <replaceable>partitionwise_item</replaceable> is:</phrase>
+
+{ <replaceable>advice_target</replaceable> |
+( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
+
+   <para>
+    When applied to a single target, <literal>PARTITIONWISE</literal>
+    specifies that the specified table should not be part of any partitionwise
+    join. When applied to a list of targets, <literal>PARTITIONWISE</literal>
+    specifies that exactly that set of relations should be joined in
+    partitionwise fashion. Note that, regardless of what advice is specified,
+    no partitionwise joins will be possible if
+    <literal>enable_partitionwise_join = off</literal>.
+   </para>
+
+  </sect3>
+
+  <sect3 id="pgplanadvice-semijoin-unique">
+  <title>Semijoin Uniqueness Advice</title>
+   <synopsis>
+SEMIJOIN_UNIQUE(<replaceable>sj_unique_item</replaceable> [ ... ])
+SEMIJOIN_NON_UNIQUE(<replaceable>sj_unique_item</replaceable> [ ... ])
+
+<phrase>where <replaceable>sj_unique_item</replaceable> is:</phrase>
+
+{ <replaceable>advice_target</replaceable> |
+( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
+
+   <para>
+    The planner sometimes has a choice between implementing a semijoin
+    directly and implementing a semijoin by making the nullable side unique
+    and then performing an inner join. <literal>SEMIJOIN_UNIQUE</literal>
+    specifies the latter strategy, while <literal>SEMIJOIN_NON_UNIQUE</literal>
+    specifies the former strategy. In either case, the argument is the single
+    relation or list of relations that appear beneath the nullable side of the
+    join.
+   </para>
+
+  </sect3>
+
+  <sect3 id="pgplanadvice-parallel-query">
+  <title>Parallel Query Advice</title>
+   <synopsis>
+GATHER(<replaceable>gather_item</replaceable> [ ... ])
+GATHER_MERGE(<replaceable>gather_item</replaceable> [ ... ])
+NO_GATHER(<replaceable>advice_target</replaceable> [ ... ])
+
+<phrase>where <replaceable>gather_item</replaceable> is:</phrase>
+
+{ <replaceable>advice_target</replaceable> |
+( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
+
+   <para>
+    <literal>GATHER</literal> or <literal>GATHER_MERGE</literal> specifies
+    that <literal>Gather</literal> or <literal>Gather Merge</literal>,
+    respectively, should be placed on top of the single relation specified as
+    a target, or on top of the join between the list of relations specified as
+    a target. This means that <literal>GATHER(a b c)</literal> is a request
+    for three different <literal>Gather</literal> nodes, while
+    <literal>GATHER((a b c))</literal> is a request for a single
+    <literal>Gather</literal> node on top of a 3-way join.
+   </para>
+
+   <para>
+    <literal>NO_GATHER</literal> specifies that no <literal>Gather</literal> or
+    <literal>Gather Merge</literal> node should appear above any of the
+    targets, but it only constrains the planning of an individual subquery,
+    and outer subquery levels can still use parallel query. For example,
+    <literal>NO_GATHER(inner_example@any_1)</literal> precludes using a
+    <literal>Parallel Seq Scan</literal> to access the
+    <literal>inner_example</literal> table within the <literal>any_1</literal>
+    subquery, but it does not prevent the planner from placing
+    <literal>SubPlan any_1</literal> beneath a <literal>Gather</literal>
+    or <literal>Gather Merge</literal> node. The following plan is
+    compatible with <literal>NO_GATHER(inner_example@any_1)</literal>, but
+    not with <literal>NO_GATHER(outer_example)</literal>:
+   </para>
+
+<programlisting>
+ Finalize Aggregate
+   ->  Gather
+         ->  Partial Aggregate
+               ->  Parallel Seq Scan on outer_example
+                     Filter: (something = (hashed SubPlan any_1).col1)
+                     SubPlan any_1
+                       ->  Seq Scan on inner_example
+                             Filter: (something_else > 100)
+</programlisting>
+
+   <para>
+    Here is the reverse case, that is, a plan compatible with
+    <literal>NO_GATHER(outer_example)</literal> but not with
+    <literal>NO_GATHER(inner_example@any_1)</literal>:
+   </para>
+
+<programlisting>
+ Aggregate
+   ->  Seq Scan on outer_example
+         Filter: (something = (hashed SubPlan any_1).col1)
+         SubPlan any_1
+           ->  Gather
+                 -> Parallel Seq Scan on inner_example
+                      Filter: (something_else > 100)
+</programlisting>
+
+  </sect3>
+ </sect2>
+
+ <sect2 id="pgplanadvice-feedback">
+  <title>Advice Feedback</title>
+
+  <para>
+   <literal>EXPLAIN</literal> provides feedback on whether supplied advice was
+   successfully applied to the query in the form of a comment on each piece
+   of supplied advice. For example:
+  </para>
+
+<programlisting>
+SET pg_plan_advice.advice = 'hash_join(f g) join_order(f g) index_scan(f no_such_index)';
+EXPLAIN (COSTS OFF)
+    SELECT * FROM jo_fact f
+    LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+    LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+    WHERE val1 = 1 AND val2 = 1;
+                            QUERY PLAN
+-------------------------------------------------------------------
+ Hash Join
+   Hash Cond: ((d1.id = f.dim1_id) AND (d2.id = f.dim2_id))
+   ->  Nested Loop
+         ->  Seq Scan on jo_dim2 d2
+               Filter: (val2 = 1)
+         ->  Materialize
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on jo_fact f
+ Supplied Plan Advice:
+   INDEX_SCAN(f no_such_index) /* matched, inapplicable, failed */
+   HASH_JOIN(f) /* matched */
+   HASH_JOIN(g) /* not matched */
+   JOIN_ORDER(f g) /* partially matched */
+</programlisting>
+
+  <para>
+   For this query, <literal>f</literal> is a valid advice target, but
+   <literal>g</literal> is not. Therefore, the request to place
+   <literal>f</literal> on the inner side of a hash join is listed as
+   <literal>matched</literal>, but the request to place <literal>g</literal>
+   on the inner side of a hash join is listed as
+   <literal>not matched</literal>. The <literal>JOIN_ORDER</literal> advice
+   tag involves one valid target and one invalid target, and so is listed as
+   <literal>partially matched</literal>. Note that
+   <literal>HASH_JOIN(f g)</literal> is actually a request for two logically
+   separate behaviors, so in the feedback it is decomposed into
+   <literal>HASH_JOIN(f)</literal> and <literal>HASH_JOIN(g)</literal>.
+   By contrast, <literal>JOIN_ORDER(f g)</literal> is a single request and
+   appears as-is.
+  </para>
+
+  <para>
+   Advice feedback can include any of the following:
+  </para>
+
+  <itemizedlist>
+
+   <listitem>
+    <para>
+     <literal>matched</literal> means that all of the specified advice targets
+     were observed together during query planning, at a time at which the
+     advice could be enforced.
+    </para>
+   </listitem>
+
+   <listitem>
+    <para>
+     <literal>partially matched</literal> means that some but not all of the
+     specified advice targets were observed during query planning, or all
+     of the advice targets were observed but not together. For example, this
+     may happen if all the targets of <literal>JOIN_ORDER</literal> advice
+     individually match the query, but the proposed join order is not legal.
+    </para>
+   </listitem>
+
+   <listitem>
+    <para>
+     <literal>not matched</literal> means that none of the
+     specified advice targets were observed during query planning. This may
+     happen if the advice simply doesn't match the query, or it may
+     occur if the relevant portion of the query was not planned, perhaps
+     because it was gated by a condition that was simplified to constant false.
+    </para>
+   </listitem>
+
+   <listitem>
+    <para>
+     <literal>inapplicable</literal> means that the advice tag could not
+     be applied to the advice targets for some reason. For example, this will
+     happen if the use of a nonexistent index is requested, or if an attempt
+     is made to control semijoin uniqueness for a non-semijoin.
+    </para>
+   </listitem>
+
+   <listitem>
+    <para>
+     <literal>conflicting</literal> means that two or more pieces of advice
+     request incompatible behaviors. For example, if you advise a sequential
+     scan and an index scan for the same table, both requests will be flagged
+     as conflicting. This also commonly happens if join method advice or
+     semijoin uniqueness advice implies a join order incompatible with the
+     one explicitly specified; see
+     <xref linkend="pgplanadvice-join-method" />.
+    </para>
+   </listitem>
+
+   <listitem>
+    <para>
+     <literal>failed</literal> means that the query plan does not comply with
+     the advice. This only occurs for entries that are also shown as
+     <literal>matched</literal>. It frequently occurs for entries that are
+     also marked as <literal>conflicting</literal> or
+     <literal>inapplicable</literal>. However, it can also occur when the
+     advice is valid insofar as <literal>pg_plan_advice</literal> is able
+     to determine, but the planner is not able to construct a legal
+     plan that can comply with the advice. It is important to note that the
+     sanity checks performed by <literal>pg_plan_advice</literal> are fairly
+     superficial and focused mostly on looking for logical inconsistencies in
+     the advice string; only the planner knows what will actually work.
+    </para>
+   </listitem>
+
+  </itemizedlist>
+
+  <para>
+   All advice should be marked as exactly one of <literal>matched</literal>,
+   <literal>partially matched</literal>, or <literal>not matched</literal>.
+  </para>
+
+ </sect2>
+
+ <sect2 id="pgplanadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_plan_advice.advice</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_plan_advice.advice</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_plan_advice.advice</varname> is an advice string to be
+      used during query planning.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_plan_advice.always_explain_supplied_advice</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_plan_advice.always_explain_supplied_advice</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_plan_advice.always_explain_supplied_advice</varname> causes
+      <literal>EXPLAIN</literal> to always show any supplied advice and the
+      associated
+      <link linkend="pgplanadvice-feedback">advice feedback</link>.
+      The default value is <literal>true</literal>. If set to
+      <literal>false</literal>, this information will be displayed only when
+      <literal>EXPLAIN (PLAN_ADVICE)</literal> is used.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_plan_advice.always_store_advice_details</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_plan_advice.always_store_advice_details</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_plan_advice.always_store_advice_details</varname> allows
+      <literal>EXPLAIN</literal> to show details related to plan advice even
+      when prepared queries are used. The default value is
+      <literal>false</literal>. When planning a prepared query, it is not
+      possible to know whether <literal>EXPLAIN</literal> will later be used,
+      so by default, to reduce overhead, <literal>pg_plan_advice</literal>
+      will not generate plan advice or feedback on supplied advice. This means
+      that if <literal>EXPLAIN EXECUTE</literal> is used on the prepared query,
+      it will not be able to show this information. Changing this setting to
+      <literal>true</literal> avoids this problem, but adds additional
+      overhead. It is probably a good idea to enable this option only in
+      sessions where it is needed, rather than on a system-wide basis.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_plan_advice.feedback_warnings</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_plan_advice.feedback_warnings</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      When set to true, <varname>pg_plan_advice.feedback_warnings</varname>
+      emits a warning whenever supplied plan advice is not successfully
+      enforced. The default value is <literal>false</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_plan_advice.trace_mask</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_plan_advice.trace_mask</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      When <varname>pg_plan_advice.trace_mask</varname> is
+      <literal>true</literal>, <literal>pg_plan_advice</literal> will print
+      messages during query planning each time that
+      <literal>pg_plan_advice</literal> alters the mask of allowable query
+      plan types in response to supplied plan advice. The default value is
+      <literal>false</literal>. The messages printed by this setting are not
+      expected to be useful except for purposes of debugging this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgplanadvice-limitations">
+  <title>Limitations</title>
+
+  <para>
+   It is currently not possible to control any aspect of the planner's behavior
+   with respect to aggregation. This includes both whether aggregates are
+   computed by sorting or hashing, and also whether strategies such as
+   <link linkend="guc-enable-eager-aggregate">eager aggregation</link> or
+   <link linkend="guc-enable-partitionwise-aggregate">partitionwise
+   aggregation</link> are used.
+  </para>
+
+  <para>
+   It also is currently not possible to control any aspect of the planner's
+   behavior with respect to set operations such as <literal>UNION</literal>
+   or <literal>INTERSECT</literal>.
+  </para>
+
+  <para>
+   As discussed above under <link linkend="pgplanadvice-how-it-works">How
+   It Works</link>, the use of plan advice can only affect which plan
+   the planner chooses from among those it believes to be viable. It can never
+   force the choice of a plan which the planner refused to consider in the
+   first place.
+  </para>
+ </sect2>
+
+ <sect2 id="pgplanadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3250564d4ff..e4ba0146ffb 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3984,6 +3984,39 @@ pg_uuid_t
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgpa_advice_item
+pgpa_advice_tag_type
+pgpa_advice_target
+pgpa_identifier
+pgpa_index_target
+pgpa_index_type
+pgpa_itm_type
+pgpa_jo_outcome
+pgpa_join_class
+pgpa_join_member
+pgpa_join_state
+pgpa_join_strategy
+pgpa_join_unroller
+pgpa_output_context
+pgpa_plan_walker_context
+pgpa_planner_state
+pgpa_qf_type
+pgpa_query_feature
+pgpa_ri_checker
+pgpa_ri_checker_key
+pgpa_scan
+pgpa_scan_strategy
+pgpa_sj_unique_rel
+pgpa_target_type
+pgpa_trove
+pgpa_trove_entry
+pgpa_trove_entry_element
+pgpa_trove_entry_hash
+pgpa_trove_entry_key
+pgpa_trove_lookup_type
+pgpa_trove_result
+pgpa_trove_slice
+pgpa_unrolled_join
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-12 17:15  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 5 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-12 17:15 UTC (permalink / raw)
  To: David G. Johnston <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Tue, Mar 10, 2026 at 10:55 AM Robert Haas <[email protected]> wrote:
> Thanks. Here's v19.

I've now committed the main patch. I think that I'm not likely to get
too much more feedback on that in this release cycle, and I judge that
more people will be sad if it doesn't get shipped this cycle than if
it does. That might turn out to be catastrophically wrong, but if many
problem reports quickly begin to emerge, it can always be reverted.
I'm hopeful my testing has been thorough enough to avoid that, but
there's a big difference between lab-testing and real world usage, so
we'll see.

I'm still hoping to get some more feedback on the remaining patches,
which are much smaller and conceptually simpler. While there is no
time to redesign them at this point in the release cycle, there is
still the opportunity to fix bugs, or decide that they're too
half-baked to ship. So here is v20 with just those patches. Of course,
post-commit review of the main patch is also very welcome.

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v20-0002-Test-pg_plan_advice-using-a-new-test_plan_advice.patch (10.5K, 2-v20-0002-Test-pg_plan_advice-using-a-new-test_plan_advice.patch)
  download | inline diff:
From ce7dea12edf7748a0a8abfc59ff57ccdaf168bb5 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Sat, 7 Feb 2026 09:36:08 -0500
Subject: [PATCH v20 2/3] Test pg_plan_advice using a new test_plan_advice
 module.

The TAP test included in this new module runs the regression tests
with pg_plan_advice loaded. It arranges for each query to be planned
twice.  The first time, we generate plan advice. The second time, we
replan the query using the resulting advice string. If the tests
fail, that means that using pg_plan_advice to tell the planner to
do what it was going to do anyway breaks something, which indicates
a problem either with pg_plan_advice or with the planner.

The test also enables pg_plan_advice.feedback_warnings, so that if the
plan advice fails to apply cleanly when the query is replanned, a
failure will occur.
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 src/test/modules/test_plan_advice/Makefile    |  28 ++++
 src/test/modules/test_plan_advice/meson.build |  29 ++++
 .../test_plan_advice/t/001_replan_regress.pl  |  65 ++++++++
 .../test_plan_advice/test_plan_advice.c       | 143 ++++++++++++++++++
 6 files changed, 267 insertions(+)
 create mode 100644 src/test/modules/test_plan_advice/Makefile
 create mode 100644 src/test/modules/test_plan_advice/meson.build
 create mode 100644 src/test/modules/test_plan_advice/t/001_replan_regress.pl
 create mode 100644 src/test/modules/test_plan_advice/test_plan_advice.c

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 4ac5c84db43..a1540269cf5 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -39,6 +39,7 @@ SUBDIRS = \
 		  test_oat_hooks \
 		  test_parser \
 		  test_pg_dump \
+		  test_plan_advice \
 		  test_predtest \
 		  test_radixtree \
 		  test_rbtree \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index e2b3eef4136..7c052803c98 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -40,6 +40,7 @@ subdir('test_misc')
 subdir('test_oat_hooks')
 subdir('test_parser')
 subdir('test_pg_dump')
+subdir('test_plan_advice')
 subdir('test_predtest')
 subdir('test_radixtree')
 subdir('test_rbtree')
diff --git a/src/test/modules/test_plan_advice/Makefile b/src/test/modules/test_plan_advice/Makefile
new file mode 100644
index 00000000000..be026ce34bf
--- /dev/null
+++ b/src/test/modules/test_plan_advice/Makefile
@@ -0,0 +1,28 @@
+# src/test/modules/test_plan_advice/Makefile
+
+PGFILEDESC = "test_plan_advice - test whether generated plan advice works"
+
+MODULE_big = test_plan_advice
+OBJS = \
+	$(WIN32RES) \
+	test_plan_advice.o
+
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_plan_advice
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+override CPPFLAGS += -I$(top_srcdir)/contrib/pg_plan_advice
+
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
diff --git a/src/test/modules/test_plan_advice/meson.build b/src/test/modules/test_plan_advice/meson.build
new file mode 100644
index 00000000000..afde420baed
--- /dev/null
+++ b/src/test/modules/test_plan_advice/meson.build
@@ -0,0 +1,29 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+test_plan_advice_sources = files(
+  'test_plan_advice.c',
+)
+
+if host_system == 'windows'
+  test_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_plan_advice',
+    '--FILEDESC', 'test_plan_advice - test whether generated plan advice works',])
+endif
+
+test_plan_advice = shared_module('test_plan_advice',
+  test_plan_advice_sources,
+  include_directories: pg_plan_advice_inc,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_plan_advice
+
+tests += {
+  'name': 'test_plan_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_replan_regress.pl',
+    ],
+  },
+}
diff --git a/src/test/modules/test_plan_advice/t/001_replan_regress.pl b/src/test/modules/test_plan_advice/t/001_replan_regress.pl
new file mode 100644
index 00000000000..38ffa4d11ae
--- /dev/null
+++ b/src/test/modules/test_plan_advice/t/001_replan_regress.pl
@@ -0,0 +1,65 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_plan_advice to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+$node->append_conf('postgresql.conf', <<EOM);
+shared_preload_libraries='test_plan_advice'
+pg_plan_advice.always_explain_supplied_advice=false
+pg_plan_advice.feedback_warnings=true
+EOM
+$node->start;
+
+my $srcdir = abs_path("../../../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+done_testing();
diff --git a/src/test/modules/test_plan_advice/test_plan_advice.c b/src/test/modules/test_plan_advice/test_plan_advice.c
new file mode 100644
index 00000000000..cff5039b5c8
--- /dev/null
+++ b/src/test/modules/test_plan_advice/test_plan_advice.c
@@ -0,0 +1,143 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_plan_advice.c
+ *	  Test pg_plan_advice by planning every query with generated advice.
+ *
+ * With this module loaded, every time a query is executed, we end up
+ * planning it twice. The first time we plan it, we generate plan advice,
+ * which we then feed back to pg_plan_advice as the supplied plan advice.
+ * It is then planned a second time using that advice. This hopefully
+ * allows us to detect cases where the advice is incorrect or causes
+ * failures or plan changes for some reason.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  src/test/modules/test_plan_advice/test_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/xact.h"
+#include "fmgr.h"
+#include "optimizer/optimizer.h"
+#include "pg_plan_advice.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static bool in_recursion = false;
+
+static char *test_plan_advice_advisor(PlannerGlobal *glob,
+									  Query *parse,
+									  const char *query_string,
+									  int cursorOptions,
+									  ExplainState *es);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/*
+	 * Ask pg_plan_advice to get advice strings from test_plan_advice_advisor
+	 */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+
+	(*add_advisor_fn) (test_plan_advice_advisor);
+}
+
+/*
+ * Re-plan the given query and return the generated advice string as the
+ * supplied advice.
+ */
+static char *
+test_plan_advice_advisor(PlannerGlobal *glob, Query *parse,
+						 const char *query_string, int cursorOptions,
+						 ExplainState *es)
+{
+	PlannedStmt *pstmt;
+	int			save_nestlevel = 0;
+	DefElem    *pgpa_item;
+	DefElem    *advice_string_item;
+
+	/*
+	 * Since this function is called from the planner and triggers planning,
+	 * we need a recursion guard.
+	 */
+	if (in_recursion)
+		return NULL;
+
+	PG_TRY();
+	{
+		in_recursion = true;
+
+		/*
+		 * Planning can trigger expression evaluation, which can result in
+		 * sending NOTICE messages or other output to the client. To avoid
+		 * that, we set client_min_messages = ERROR in the hopes of getting
+		 * the same output with and without this module.
+		 *
+		 * We also need to set pg_plan_advice.always_store_advice_details so
+		 * that pg_plan_advice will generate an advice string, since the whole
+		 * point of this function is to get access to that.
+		 */
+		save_nestlevel = NewGUCNestLevel();
+		set_config_option("client_min_messages", "error",
+						  PGC_SUSET, PGC_S_SESSION,
+						  GUC_ACTION_SAVE, true, 0, false);
+		set_config_option("pg_plan_advice.always_store_advice_details", "true",
+						  PGC_SUSET, PGC_S_SESSION,
+						  GUC_ACTION_SAVE, true, 0, false);
+
+		/*
+		 * Replan. We must copy the Query, because the planner modifies it.
+		 * (As noted elsewhere, that's unfortunate; perhaps it will be fixed
+		 * some day.)
+		 */
+		pstmt = planner(copyObject(parse), query_string, cursorOptions,
+						glob->boundParams, es);
+	}
+	PG_FINALLY();
+	{
+		in_recursion = false;
+	}
+	PG_END_TRY();
+
+	/* Roll back any GUC changes */
+	if (save_nestlevel > 0)
+		AtEOXact_GUC(false, save_nestlevel);
+
+	/* Extract and return the advice string */
+	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
+										"pg_plan_advice");
+	if (pgpa_item == NULL)
+		elog(ERROR, "extension state for pg_plan_advice not found");
+	advice_string_item = find_defelem_by_defname((List *) pgpa_item->arg,
+												 "advice_string");
+	if (advice_string_item == NULL)
+		elog(ERROR,
+			 "advice string for pg_plan_advice not found in extension state");
+	return strVal(advice_string_item->arg);
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
-- 
2.51.0



  [application/octet-stream] v20-0001-Add-pg_collect_advice-contrib-module.patch (56.1K, 3-v20-0001-Add-pg_collect_advice-contrib-module.patch)
  download | inline diff:
From 71c3c4d3d7de68510cb6e5e6e59809035a78eb46 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Thu, 26 Feb 2026 16:51:16 -0500
Subject: [PATCH v20 1/3] Add pg_collect_advice contrib module.

This module allows automated, large-scale collection of queries and
the associated plan advice strings using either backend-local memory
or dynamic shared memory. In either case, memory usage can be limited
by restriction the maximum number of queries and advice strings
stored. Care should be taken with these values, and with the use of
this module in general, because it's easy to chew up an unreasonably
large amount of memory. Unlike pg_stat_statements, this module does
not provide for query normalization or even deduplication; it simply
makes a record for every query planned.

It can be useful to enable query ID computaton before using the
module, but it's not required. If not done, all queries will simply
show a query ID of zero.

Reviewed-by: Alexandra Wang <[email protected]> (earlier version)
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_collect_advice/Makefile            |  29 +
 contrib/pg_collect_advice/collector.c         | 641 ++++++++++++++++++
 .../expected/local_collector.out              |  69 ++
 contrib/pg_collect_advice/interface.c         | 303 +++++++++
 contrib/pg_collect_advice/meson.build         |  41 ++
 .../pg_collect_advice--1.0.sql                |  43 ++
 .../pg_collect_advice.control                 |   5 +
 contrib/pg_collect_advice/pg_collect_advice.h |  39 ++
 .../pg_collect_advice/sql/local_collector.sql |  46 ++
 contrib/pg_collect_advice/t/001_regress.pl    | 151 +++++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgcollectadvice.sgml             | 244 +++++++
 src/tools/pgindent/typedefs.list              |   6 +
 16 files changed, 1621 insertions(+)
 create mode 100644 contrib/pg_collect_advice/Makefile
 create mode 100644 contrib/pg_collect_advice/collector.c
 create mode 100644 contrib/pg_collect_advice/expected/local_collector.out
 create mode 100644 contrib/pg_collect_advice/interface.c
 create mode 100644 contrib/pg_collect_advice/meson.build
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice--1.0.sql
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.control
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.h
 create mode 100644 contrib/pg_collect_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_collect_advice/t/001_regress.pl
 create mode 100644 doc/src/sgml/pgcollectadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index dd04c20acd2..22071034e51 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -31,6 +31,7 @@ SUBDIRS = \
 		pageinspect	\
 		passwordcheck	\
 		pg_buffercache	\
+		pg_collect_advice \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
diff --git a/contrib/meson.build b/contrib/meson.build
index 5a752eac347..ff422d9b7fc 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -45,6 +45,7 @@ subdir('pageinspect')
 subdir('passwordcheck')
 subdir('pg_buffercache')
 subdir('pgcrypto')
+subdir('pg_collect_advice')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
diff --git a/contrib/pg_collect_advice/Makefile b/contrib/pg_collect_advice/Makefile
new file mode 100644
index 00000000000..33f715606f9
--- /dev/null
+++ b/contrib/pg_collect_advice/Makefile
@@ -0,0 +1,29 @@
+# contrib/pg_collect_advice/Makefile
+
+MODULE_big = pg_collect_advice
+OBJS = \
+	$(WIN32RES) \
+	collector.o \
+	interface.o
+
+EXTENSION = pg_collect_advice
+DATA = pg_collect_advice--1.0.sql
+PGFILEDESC = "pg_collect_advice - collect queries and their plan advice strings"
+
+REGRESS = local_collector
+TAP_TESTS = 1
+
+# required for 001_regress.pl
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_collect_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_collect_advice/collector.c b/contrib/pg_collect_advice/collector.c
new file mode 100644
index 00000000000..053a22245b2
--- /dev/null
+++ b/contrib/pg_collect_advice/collector.c
@@ -0,0 +1,641 @@
+/*-------------------------------------------------------------------------
+ *
+ * collector.c
+ *	  workhorse for saving plan advice in backend-local or shared memory
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgca_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgca_collected_advice;
+
+/*
+ * A bunch of pointers to pgca_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgca_local_advice_chunk
+{
+	pgca_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgca_local_advice_chunk;
+
+/*
+ * Information about all of the pgca_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgca_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgca_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgca_local_advice_chunk **chunks;
+} pgca_local_advice;
+
+/*
+ * Just like pgca_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgca_shared_advice_chunk;
+
+/*
+ * Just like pgca_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgca_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgca_local_advice *local_collector = NULL;
+static pgca_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgca_collected_advice *make_collected_advice(Oid userid,
+													Oid dbid,
+													uint64 queryId,
+													TimestampTz timestamp,
+													const char *query_string,
+													const char *advice_string,
+													dsa_area *area,
+													dsa_pointer *result);
+static void store_local_advice(pgca_collected_advice *ca);
+static void trim_local_advice(int limit);
+static void store_shared_advice(dsa_pointer ca_pointer);
+static void trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgca_collected_advice */
+static inline const char *
+query_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgca_collected_advice */
+static inline const char *
+advice_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pg_collect_advice_save(uint64 queryId, const char *query_string,
+					   const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_collect_advice_local_collector &&
+		pg_collect_advice_local_collection_limit > 0)
+	{
+		pgca_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+		ca = make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string,
+								   NULL, NULL);
+		store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_collect_advice_shared_collector &&
+		pg_collect_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_collect_advice_dsa_area();
+		dsa_pointer ca_pointer = InvalidDsaPointer; /* placate compiler */
+
+		make_collected_advice(userid, dbid, queryId, now,
+							  query_string, advice_string, area,
+							  &ca_pointer);
+		store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgca_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgca_collected_advice *
+make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+					  TimestampTz timestamp,
+					  const char *query_string,
+					  const char *advice_string,
+					  dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgca_collected_advice *ca;
+
+	total_length = offsetof(pgca_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = userid;
+	ca->dbid = dbid;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pgca_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+store_local_advice(pgca_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgca_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgca_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgca_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgca_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_local_advice(pg_collect_advice_local_collection_limit);
+}
+
+/*
+ * Add a pgca_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_collect_advice DSA area
+ * and should point to an object of type pgca_collected_advice.
+ */
+static void
+store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	pgca_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgca_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgca_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgca_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_shared_advice(area, pg_collect_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+trim_local_advice(int limit)
+{
+	pgca_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgca_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgca_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+trim_shared_advice(dsa_area *area, int limit)
+{
+	pgca_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(dsa_pointer) * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in shared memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgca_shared_advice *sa = shared_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_collect_advice/expected/local_collector.out b/contrib/pg_collect_advice/expected/local_collector.out
new file mode 100644
index 00000000000..f57b96ee835
--- /dev/null
+++ b/contrib/pg_collect_advice/expected/local_collector.out
@@ -0,0 +1,69 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_collect_advice/interface.c b/contrib/pg_collect_advice/interface.c
new file mode 100644
index 00000000000..feb11974152
--- /dev/null
+++ b/contrib/pg_collect_advice/interface.c
@@ -0,0 +1,303 @@
+/*-------------------------------------------------------------------------
+ *
+ * interface.c
+ *	  interface routines for the plan advice collector
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/interface.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+/* Shared memory pointers */
+static pgca_shared_state *pgca_state = NULL;
+static dsa_area *pgca_dsa_area = NULL;
+
+/* GUC variables */
+bool		pg_collect_advice_local_collector = false;
+int			pg_collect_advice_local_collection_limit = 0;
+bool		pg_collect_advice_shared_collector = false;
+int			pg_collect_advice_shared_collection_limit = 0;
+
+/* Shadow variables for GUC assign hooks */
+static bool pg_collect_advice_local_collector_as_assigned = false;
+static bool pg_collect_advice_shared_collector_as_assigned = false;
+
+/* Other file-level globals */
+static void (*request_advice_generation_fn) (bool activate) = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+static MemoryContext pgca_memory_context = NULL;
+
+/* Function prototypes */
+static void pgca_init_shared_state(void *ptr, void *arg);
+static void pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string,
+								  PlannedStmt *pstmt);
+static void pg_collect_advice_local_collector_assign_hook(bool newval,
+														  void *extra);
+static void pg_collect_advice_shared_collector_assign_hook(bool newval,
+														   void *extra);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	/*
+	 * Get a pointer so we can call pg_plan_advice_request_advice_generation.
+	 *
+	 * We need to do this before defining custom GUCs; otherwise, our assign
+	 * hook will try to use this function pointer before it's initialized.
+	 *
+	 * We also need to do this before installing our own hooks, so that if
+	 * pg_plan_advice is not yet loaded, it will install its hooks before we
+	 * install ours. (See comments in pgca_planner_shutdown.)
+	 */
+	request_advice_generation_fn =
+		load_external_function("pg_plan_advice",
+							   "pg_plan_advice_request_advice_generation",
+							   true, NULL);
+
+	/* Define our GUCs. */
+	DefineCustomBoolVariable("pg_collect_advice.local_collector",
+							 "Enable the local advice collector.",
+							 NULL,
+							 &pg_collect_advice_local_collector,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_local_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_collect_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomBoolVariable("pg_collect_advice.shared_collector",
+							 "Enable the shared advice collector.",
+							 NULL,
+							 &pg_collect_advice_shared_collector,
+							 false,
+							 PGC_SUSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_shared_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_collect_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_collect_advice");
+
+	/* Install hooks */
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgca_planner_shutdown;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgca_init_shared_state(void *ptr, void *arg)
+{
+	pgca_shared_state *state = (pgca_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_collect_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_collect_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_collect_advice_get_mcxt(void)
+{
+	if (pgca_memory_context == NULL)
+		pgca_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_collect_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgca_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ */
+pgca_shared_state *
+pg_collect_advice_attach(void)
+{
+	if (pgca_state == NULL)
+	{
+		bool		found;
+
+		pgca_state =
+			GetNamedDSMSegment("pg_collect_advice", sizeof(pgca_shared_state),
+							   pgca_init_shared_state, &found, NULL);
+	}
+
+	return pgca_state;
+}
+
+/*
+ * Return a pointer to pg_collect_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_collect_advice_dsa_area(void)
+{
+	if (pgca_dsa_area == NULL)
+	{
+		pgca_shared_state *state = pg_collect_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgca_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgca_dsa_area);
+			state->area = dsa_get_handle(pgca_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgca_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgca_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgca_dsa_area;
+}
+
+/*
+ * After planning is complete, retrieve the advice string, if present, and
+ * pass it through to the collector.
+ */
+static void
+pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	DefElem    *pgpa_item;
+	DefElem    *advice_string_item;
+	char	   *advice_string;
+
+	/*
+	 * Pass call to previous hook.
+	 *
+	 * We want to be called after pg_plan_advice's shutdown hook has already
+	 * executed. Our _PG_init() makes sure that pg_plan_advice's hooks are
+	 * always loaded before ours, and here we pass the hook call down first,
+	 * before doing our own work. The combination of those two things should
+	 * be good enough to ensure that the advice string is already present when
+	 * we go looking for it.
+	 */
+	if (prev_planner_shutdown)
+		(*prev_planner_shutdown) (glob, parse, query_string, pstmt);
+
+	/* Fish out the advice string. If not found, do nothing. */
+	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
+										"pg_plan_advice");
+	if (pgpa_item == NULL)
+		return;
+	advice_string_item = find_defelem_by_defname((List *) pgpa_item->arg,
+												 "advice_string");
+	if (advice_string_item == NULL)
+		return;
+	advice_string = strVal(advice_string_item->arg);
+
+	/*
+	 * Pass it through to the actual collector. But, if it's the empty string,
+	 * we assume that collecting it is uninteresting.
+	 */
+	if (advice_string[0] != '\0')
+		pg_collect_advice_save(pstmt->queryId, query_string, advice_string);
+}
+
+/*
+ * pgca_planner_shutdown won't find any advice to collect unless we've
+ * requested that it be generated. So, whenever the effective value of
+ * pg_collect_advice.local_collector changes, either make or
+ * revoke a request for advice generation.
+ */
+static void
+pg_collect_advice_local_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_local_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_local_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_local_collector_as_assigned = newval;
+}
+
+/*
+ * Same as above, but for pg_collect_advice.shared_collector
+ */
+static void
+pg_collect_advice_shared_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_shared_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_shared_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_shared_collector_as_assigned = newval;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_collect_advice/meson.build b/contrib/pg_collect_advice/meson.build
new file mode 100644
index 00000000000..ca7d5ecff1a
--- /dev/null
+++ b/contrib/pg_collect_advice/meson.build
@@ -0,0 +1,41 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_collect_advice_sources = files(
+  'collector.c',
+  'interface.c',
+)
+
+if host_system == 'windows'
+  pg_collect_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_collect_advice',
+    '--FILEDESC', 'pg_collect_advice - collect queries and their plan advice strings',])
+endif
+
+pg_collect_advice = shared_module('pg_collect_advice',
+  pg_collect_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_collect_advice
+
+install_data(
+  'pg_collect_advice--1.0.sql',
+  'pg_collect_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_collect_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'local_collector',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_regress.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_collect_advice/pg_collect_advice--1.0.sql b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
new file mode 100644
index 00000000000..0be86c54fc1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_collect_advice/pg_collect_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_collect_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_clear_collected_shared_advice() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_collect_advice/pg_collect_advice.control b/contrib/pg_collect_advice/pg_collect_advice.control
new file mode 100644
index 00000000000..601e5e24ea1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.control
@@ -0,0 +1,5 @@
+# pg_collect_advice extension
+comment = 'collect queries and the associated plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_collect_advice'
+relocatable = true
diff --git a/contrib/pg_collect_advice/pg_collect_advice.h b/contrib/pg_collect_advice/pg_collect_advice.h
new file mode 100644
index 00000000000..480c2c633c4
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.h
@@ -0,0 +1,39 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_collect_advice.h
+ *	  definitions and declarations for pg_collect_advice module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/pg_collect_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_COLLECT_ADVICE_H
+#define PG_COLLECT_ADVICE_H
+
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgca_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgca_shared_state;
+
+/* GUC variables */
+extern bool pg_collect_advice_local_collector;
+extern int	pg_collect_advice_local_collection_limit;
+extern bool pg_collect_advice_shared_collector;
+extern int	pg_collect_advice_shared_collection_limit;
+
+/* Function prototypes */
+extern MemoryContext pg_collect_advice_get_mcxt(void);
+extern pgca_shared_state *pg_collect_advice_attach(void);
+extern dsa_area *pg_collect_advice_dsa_area(void);
+extern void pg_collect_advice_save(uint64 queryId, const char *query_string,
+								   const char *advice_string);
+
+#endif
diff --git a/contrib/pg_collect_advice/sql/local_collector.sql b/contrib/pg_collect_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..41b187c5375
--- /dev/null
+++ b/contrib/pg_collect_advice/sql/local_collector.sql
@@ -0,0 +1,46 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_collect_advice/t/001_regress.pl b/contrib/pg_collect_advice/t/001_regress.pl
new file mode 100644
index 00000000000..ed934d0c859
--- /dev/null
+++ b/contrib/pg_collect_advice/t/001_regress.pl
@@ -0,0 +1,151 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_collect_advice and pg_plan_advice
+# to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+#
+# We run with pg_collect_advice.shared_collection_limit set to ensure that the
+# plan tree walker code runs against every query in the regression tests. If
+# we're unable to properly analyze any of those plan trees, this test should
+# hopefully fail.
+#
+# We set pg_collect_advice.advice to an advice string that will cause the advice
+# trove to be populated with a few entries of various sorts, but which we do
+# not expect to match anything in the regression test queries. This way, the
+# planner hooks will be called, improving code coverage, but no plans should
+# actually change.
+#
+# pg_plan_advice.always_explain_supplied_advice=false is needed to avoid
+# breaking regression test queries that use EXPLAIN. In the real world, it
+# seems like users will want EXPLAIN output to show supplied advice so that
+# it's clear whether normal planner behavior has been altered, but here that's
+# undesirable.
+$node->append_conf('postgresql.conf', <<EOM);
+shared_preload_libraries=pg_collect_advice
+pg_collect_advice.shared_collection_limit=1000000
+pg_collect_advice.shared_collector=true
+pg_plan_advice.advice='SEQ_SCAN(entirely_fictitious) HASH_JOIN(total_fabrication) GATHER(completely_imaginary)'
+pg_plan_advice.always_explain_supplied_advice=false
+EOM
+$node->start;
+
+my $srcdir = abs_path("../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+# Create the extension so we can access the collector
+$node->safe_psql('postgres', 'CREATE EXTENSION pg_collect_advice');
+
+# Verify that a large amount of advice was collected
+my $all_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice();
+EOM
+cmp_ok($all_query_count, '>', 20000, "copious advice collected");
+
+# Verify that lots of different advice strings were collected
+my $distinct_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM
+	(SELECT DISTINCT advice FROM pg_get_collected_shared_advice());
+EOM
+cmp_ok($distinct_query_count, '>', 3000, "diverse advice collected");
+
+# We want to test for the presence of our known tags in the collected advice.
+# Put all tags into the hash that follows; map any tags that aren't tested
+# by the core regression tests to 0, and others to 1.
+my %tag_map = (
+	BITMAP_HEAP_SCAN => 1,
+	FOREIGN_JOIN => 0,
+	GATHER => 1,
+	GATHER_MERGE => 1,
+	HASH_JOIN => 1,
+	INDEX_ONLY_SCAN => 1,
+	INDEX_SCAN => 1,
+	JOIN_ORDER => 1,
+	MERGE_JOIN_MATERIALIZE => 1,
+	MERGE_JOIN_PLAIN => 1,
+	NESTED_LOOP_MATERIALIZE => 1,
+	NESTED_LOOP_MEMOIZE => 1,
+	NESTED_LOOP_PLAIN => 1,
+	NO_GATHER => 1,
+	PARTITIONWISE => 1,
+	SEMIJOIN_NON_UNIQUE => 1,
+	SEMIJOIN_UNIQUE => 1,
+	SEQ_SCAN => 1,
+	TID_SCAN => 1,
+);
+for my $tag (sort keys %tag_map)
+{
+	my $checkit = $tag_map{$tag};
+
+	# Search for the given tag. This is not entirely robust: it could get thrown
+	# off by a table alias such as "FOREIGN_JOIN(", but that probably won't
+	# happen in the core regression tests.
+	my $tag_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice()
+	WHERE advice LIKE '%$tag(%'
+EOM
+
+	# Check that the tag got a non-trivial amount of use, unless told otherwise.
+	cmp_ok($tag_count, '>', 10, "multiple uses of $tag") if $checkit;
+
+	# Regardless, note the exact count in the log, for human consumption.
+	note("found $tag_count advice strings containing $tag");
+}
+
+# Trigger a partial cleanup of the shared advice collector, and then a full
+# cleanup.
+$node->safe_psql('postgres', <<EOM);
+SET pg_collect_advice.shared_collection_limit=500;
+SELECT * FROM pg_clear_collected_shared_advice();
+EOM
+
+done_testing();
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index bdd4865f53f..2ab6fafbab1 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -152,6 +152,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pageinspect;
  &passwordcheck;
  &pgbuffercache;
+ &pgcollectadvice;
  &pgcrypto;
  &pgfreespacemap;
  &pglogicalinspect;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index d90b4338d2a..407ff3abffe 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -145,6 +145,7 @@
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
+<!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
 <!ENTITY pglogicalinspect  SYSTEM "pglogicalinspect.sgml">
diff --git a/doc/src/sgml/pgcollectadvice.sgml b/doc/src/sgml/pgcollectadvice.sgml
new file mode 100644
index 00000000000..220aabe78c6
--- /dev/null
+++ b/doc/src/sgml/pgcollectadvice.sgml
@@ -0,0 +1,244 @@
+<!-- doc/src/sgml/pgcollectadvice.sgml -->
+
+<sect1 id="pgcollectadvice" xreflabel="pg_collect_advice">
+ <title>pg_collect_advice &mdash; collect queries and their plan advice strings</title>
+
+ <indexterm zone="pgcollectadvice">
+  <primary>pg_collect_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_collect_advice</filename> extension allows you to
+  automatically generate plan advice each time a query is planned and store
+  the query and the generated advice string either in local or shared memory.
+  Note that this extension requires the <xref linkend="pgplanadvice" /> module,
+  which performs the actual plan advice generation; this module only knows
+  how to store the generated advice for later examination. Whenever
+  <literal>pg_collect_advice</literal> is loaded, it will automatically load
+  <literal>pg_plan_advice</literal>.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_collect_advice</literal> in at least
+  one database, so that you have a way to examine the collected advice.
+  You will also need the <literal>pg_collect_advice</literal> module
+  to be loaded in all sessions where advice is to be collected. It will
+  usually be best to do this by adding <literal>pg_collect_advice</literal>
+  to <xref linkend="guc-shared-preload-libraries"/> and restarting the
+  server.
+ </para>
+
+ <para>
+  <literal>pg_collect_advice</literal> includes both a shared advice
+  collector and a local advice collector. The local advice collector makes
+  queries and their advice strings visible only to the session where those
+  queries were planned, while the shared advice collector collects data
+  on a system-wide basis, and authorized users can examine data from all
+  sessions.
+ </para>
+
+ <para>
+  To enable a collector, you must first set a collection limit. When the
+  number of queries for which advice has been stored exceeds the collection
+  limit, the oldest queries and the corresponding advice will be discarded.
+  Then, you must adjust a separate setting to actually enable advice
+  collection. For the local collector, set the collection limit by configuring
+  <literal>pg_collect_advice.local_collection_limit</literal> to a value
+  greater than zero, and then enable advice collection by setting
+  <literal>pg_collect_advice.local_collector = true</literal>. For the shared
+  collector, the procedure is the same, except that the names of the settings
+  are <literal>pg_collect_advice.shared_collection_limit</literal> and
+  <literal>pg_collect_advice.shared_collector</literal>. Note that in both
+  cases, query texts and advice strings are stored in memory, so
+  configuring large limits may result in considerable memory consumption.
+ </para>
+
+ <para>
+  Once the collector is enabled, you can run any queries for which you wish
+  to see the generated plan advice. Then, you can examine what has been
+  collected using whichever of
+  <literal>SELECT * FROM pg_get_collected_local_advice()</literal> or
+  <literal>SELECT * FROM pg_get_collected_shared_advice()</literal>
+  corresponds to the collector you enabled. To discard the collected advice
+  and release memory, you can call
+  <literal>pg_clear_collected_local_advice()</literal>
+  or <literal>pg_clear_collected_shared_advice()</literal>.
+ </para>
+
+ <para>
+  In addition to the query texts and advice strings, the advice collectors
+  will also store the OID of the role that caused the query to be planned,
+  the OID of the database in which the query was planned, the query ID,
+  and the time at which the collection occurred. This module does not
+  automatically enable query ID computation; therefore, if you want the
+  query ID value to be populated in collected advice, be sure to configure
+  <literal>compute_query_id = on</literal>. Otherwise, the query ID may
+  always show as <literal>0</literal>.
+ </para>
+
+ <sect2 id="pgcollectadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_local_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from backend-local
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_local_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the local
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_shared_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from shared
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_shared_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the shared
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collector</varname> enables the
+      local advice collector. The default value is <literal>false</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      local advice collector. The default value is <literal>0</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collector</varname> enables the
+      shared advice collector. The default value is <literal>false</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      shared advice collector. The default value is <literal>0</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index bf1697b90a2..f8362a78191 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3988,6 +3988,12 @@ pg_uuid_t
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgca_collected_advice
+pgca_local_advice
+pgca_local_advice_chunk
+pgca_shared_advice
+pgca_shared_advice_chunk
+pgca_shared_state
 pgpa_advice_item
 pgpa_advice_tag_type
 pgpa_advice_target
-- 
2.51.0



  [application/octet-stream] v20-0003-Add-pg_stash_advice-contrib-module.patch (55.5K, 4-v20-0003-Add-pg_stash_advice-contrib-module.patch)
  download | inline diff:
From 65e35b67c350e35cb6ce5fb59a2a4b28195957fd Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 27 Feb 2026 16:58:14 -0500
Subject: [PATCH v20 3/3] Add pg_stash_advice contrib module.

This module allows plan advice strings to be provided automatically
from an in-memory advice stash. Advice stashes are stored in dynamic
shared memory and must be recreated and repopulated after a server
restart. If pg_stash.advice_stash is set to the name of an advice
stash, and if query identifiers are enabled, the query identifier
for each query will be looked up in the advice stash and the
associated advice string, if any, will be used each time that query
is planned.
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_stash_advice/Makefile              |  26 +
 .../expected/pg_stash_advice.out              | 305 ++++++
 contrib/pg_stash_advice/meson.build           |  35 +
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |  43 +
 contrib/pg_stash_advice/pg_stash_advice.c     | 878 ++++++++++++++++++
 .../pg_stash_advice/pg_stash_advice.control   |   5 +
 .../pg_stash_advice/sql/pg_stash_advice.sql   | 130 +++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgstashadvice.sgml               | 218 +++++
 src/tools/pgindent/typedefs.list              |   6 +
 13 files changed, 1650 insertions(+)
 create mode 100644 contrib/pg_stash_advice/Makefile
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice.out
 create mode 100644 contrib/pg_stash_advice/meson.build
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice--1.0.sql
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.c
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.control
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice.sql
 create mode 100644 doc/src/sgml/pgstashadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 22071034e51..14e12d4fe2e 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -30,6 +30,7 @@ SUBDIRS = \
 		oid2name	\
 		pageinspect	\
 		passwordcheck	\
+		pg_stash_advice	\
 		pg_buffercache	\
 		pg_collect_advice \
 		pg_freespacemap \
diff --git a/contrib/meson.build b/contrib/meson.build
index ff422d9b7fc..4862ba97ed1 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -52,6 +52,7 @@ subdir('pg_overexplain')
 subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
+subdir('pg_stash_advice')
 subdir('pg_stat_statements')
 subdir('pgstattuple')
 subdir('pg_surgery')
diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
new file mode 100644
index 00000000000..cd9b7f30115
--- /dev/null
+++ b/contrib/pg_stash_advice/Makefile
@@ -0,0 +1,26 @@
+# contrib/pg_stash_advice/Makefile
+
+MODULE_big = pg_stash_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_stash_advice.o
+
+EXTENSION = pg_stash_advice
+DATA = pg_stash_advice--1.0.sql
+PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
+
+REGRESS = pg_stash_advice
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+ifdef USE_PGXS
+PG_CPPFLAGS = -I$(includedir_server)/extension
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice
+subdir = contrib/pg_stash_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
new file mode 100644
index 00000000000..0de6c10cdd1
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -0,0 +1,305 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
+(13 rows)
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+(13 rows)
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           2
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+ stash_name | advice_string 
+------------+---------------
+(0 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+ERROR:  advice stash "no_such_stash" does not exist
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           1
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   | advice_string 
+---------------+---------------
+ regress_stash | SEQ_SCAN(d1)
+(1 row)
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
+SELECT pg_drop_advice_stash('regress_empty_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
new file mode 100644
index 00000000000..b666bcd0f1b
--- /dev/null
+++ b/contrib/pg_stash_advice/meson.build
@@ -0,0 +1,35 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_stash_advice_sources = files(
+  'pg_stash_advice.c'
+)
+
+if host_system == 'windows'
+  pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_stash_advice',
+    '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',])
+endif
+
+pg_stash_advice = shared_module('pg_stash_advice',
+  pg_stash_advice_sources,
+  include_directories: [pg_plan_advice_inc, include_directories('.')],
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_stash_advice
+
+install_data(
+  'pg_stash_advice--1.0.sql',
+  'pg_stash_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_stash_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pg_stash_advice',
+    ],
+  },
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
new file mode 100644
index 00000000000..88dedd8ef1b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit
+
+CREATE FUNCTION pg_create_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_create_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_drop_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_drop_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint,
+									  advice_string text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_set_stashed_advice'
+LANGUAGE C;
+
+CREATE FUNCTION pg_get_advice_stashes(
+	OUT stash_name text,
+	OUT num_entries bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stashes'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_advice_stash_contents(
+	INOUT stash_name text,
+	OUT query_id bigint,
+	OUT advice_string text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
+LANGUAGE C;
+
+REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
new file mode 100644
index 00000000000..abea9b0e161
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -0,0 +1,878 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.c
+ *	  Apply plan advice automatically, without SQL modifications.
+ *
+ * This module allows plan advice strings (as used and generated by
+ * pg_plan_advice) to be "stashed" in dynamic shared memory and, from
+ * there, automatically be applied to queries as they are planned.
+ * You can create any number of advice stashes, each of which is
+ * identified by a human-readable, ASCII name, and each of them is
+ * essentially a query ID -> advice_string mapping.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "common/string.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "lib/dshash.h"
+#include "nodes/queryjumble.h"
+#include "pg_plan_advice.h"
+#include "storage/dsm_registry.h"
+#include "storage/lwlock.h"
+#include "utils/builtins.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+PG_FUNCTION_INFO_V1(pg_create_advice_stash);
+PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
+PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
+PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
+PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+
+typedef struct pgsa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	int			stash_tranche;
+	int			entry_tranche;
+	int			next_stash_id;
+	dsa_handle	area;
+	dshash_table_handle stash_hash;
+	dshash_table_handle entry_hash;
+} pgsa_shared_state;
+
+typedef struct pgsa_stash
+{
+	char		name[NAMEDATALEN];
+	int			pgsa_stash_id;
+} pgsa_stash;
+
+typedef struct pgsa_entry_key
+{
+	int			pgsa_stash_id;
+	int64		queryId;
+} pgsa_entry_key;
+
+typedef struct pgsa_entry
+{
+	pgsa_entry_key key;
+	dsa_pointer advice_string;
+} pgsa_entry;
+
+typedef struct pgsa_stash_count
+{
+	uint32		status;
+	int			pgsa_stash_id;
+	int64		num_entries;
+} pgsa_stash_count;
+
+#define SH_PREFIX pgsa_stash_count_table
+#define SH_ELEMENT_TYPE pgsa_stash_count
+#define SH_KEY_TYPE int
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) murmurhash32((uint32) key)
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct pgsa_stash_name
+{
+	uint32		status;
+	int			pgsa_stash_id;
+	char	   *name;
+} pgsa_stash_name;
+
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE int
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) murmurhash32((uint32) key)
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/* Shared memory pointers */
+static pgsa_shared_state *pgsa_state;
+static dsa_area *pgsa_dsa_area;
+static dshash_table *pgsa_stash_dshash;
+static dshash_table *pgsa_entry_dshash;
+
+/* Shared memory hash table parameters */
+static dshash_parameters pgsa_stash_dshash_parameters = {
+	NAMEDATALEN,
+	sizeof(pgsa_stash),
+	dshash_strcmp,
+	dshash_strhash,
+	dshash_strcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+static dshash_parameters pgsa_entry_dshash_parameters = {
+	sizeof(pgsa_entry_key),
+	sizeof(pgsa_entry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+/* GUC variable */
+static char *pg_stash_advice_stash_name = "";
+
+/* Other global variables */
+static MemoryContext pg_stash_advice_mcxt;
+
+/* Function prototypes */
+static char *pgsa_advisor(PlannerGlobal *glob,
+						  Query *parse,
+						  const char *query_string,
+						  int cursorOptions,
+						  ExplainState *es);
+static void pgsa_attach(void);
+static void pgsa_check_stash_name(char *stash_name);
+static bool pgsa_check_stash_name_guc(char **newval, void **extra,
+									  GucSource source);
+static void pgsa_clear_advice_string(char *stash_name, int64 queryId);
+static void pgsa_create_stash(char *stash_name);
+static void pgsa_drop_stash(char *stash_name);
+static void pgsa_init_shared_state(void *ptr, void *arg);
+static int	pgsa_lookup_stash_id(char *stash_name);
+static void pgsa_set_advice_string(char *stash_name, int64 queryId,
+								   char *advice_string);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/* If compute_query_id = 'auto', we would like query IDs. */
+	EnableQueryId();
+
+	/* Define our GUCs. */
+	DefineCustomStringVariable("pg_stash_advice.stash_name",
+							   "Name of the advice stash to be used in this session.",
+							   NULL,
+							   &pg_stash_advice_stash_name,
+							   "",
+							   PGC_USERSET,
+							   0,
+							   pgsa_check_stash_name_guc,
+							   NULL,
+							   NULL);
+
+	MarkGUCPrefixReserved("pg_stash_advice");
+
+	/* Tell pg_plan_advice that we want to provide advice strings. */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+	(*add_advisor_fn) (pgsa_advisor);
+}
+
+/*
+ * SQL-callable function to create an advice stash
+ */
+Datum
+pg_create_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_create_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to drop an advice stash
+ */
+Datum
+pg_drop_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_drop_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to provide a list of advice stashes
+ */
+Datum
+pg_get_advice_stashes(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	pgsa_stash_count_table_hash *chash;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Tally up the number of entries per stash. */
+	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		pgsa_stash_count *c;
+		bool		found;
+
+		c = pgsa_stash_count_table_insert(chash,
+										  entry->key.pgsa_stash_id,
+										  &found);
+		if (!found)
+			c->num_entries = 1;
+		else
+			c->num_entries++;
+	}
+	dshash_seq_term(&iterator);
+
+	/* Emit results. */
+	dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[2];
+		bool		nulls[2];
+		pgsa_stash_count *c;
+
+		values[0] = CStringGetTextDatum(stash->name);
+		nulls[0] = false;
+
+		c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id);
+		values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries);
+		nulls[1] = false;
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to provide advice stash contents
+ */
+Datum
+pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	char	   *stash_name = NULL;
+	pgsa_stash_name_table_hash *nhash = NULL;
+	int			stash_id = 0;
+	pgsa_entry *entry;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* User can pass NULL for all stashes, or the name of a specific stash. */
+	if (!PG_ARGISNULL(0))
+	{
+		stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		pgsa_check_stash_name(stash_name);
+		stash_id = pgsa_lookup_stash_id(stash_name);
+
+		/* If the user specified a stash name, it should exist. */
+		if (stash_id == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("advice stash \"%s\" does not exist", stash_name));
+	}
+	else
+	{
+		pgsa_stash *stash;
+
+		/*
+		 * If we're dumping data about all stashes, we need an ID->name lookup
+		 * table.
+		 */
+		nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+		dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+		while ((stash = dshash_seq_next(&iterator)) != NULL)
+		{
+			pgsa_stash_name *n;
+			bool		found;
+
+			n = pgsa_stash_name_table_insert(nhash,
+											 stash->pgsa_stash_id,
+											 &found);
+			Assert(!found);
+			n->name = pstrdup(stash->name);
+		}
+		dshash_seq_term(&iterator);
+	}
+
+	/* Now iterate over all the entries. */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, false);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		char	   *this_stash_name;
+		char	   *advice_string;
+
+		/* Skip incomplete entries where the advice string was never set. */
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		if (stash_id != 0)
+		{
+			/*
+			 * We're only dumping data for one particular stash, so skip
+			 * entries for any other stash and use the stash name specified by
+			 * the user.
+			 */
+			if (stash_id != entry->key.pgsa_stash_id)
+				continue;
+			this_stash_name = stash_name;
+		}
+		else
+		{
+			pgsa_stash_name *n;
+
+			/*
+			 * We're dumping data for all stashes, so look up the correct name
+			 * to use in the hash table. If nothing is found, which is
+			 * possible due to race conditions, make up a string to use.
+			 */
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n != NULL)
+				this_stash_name = n->name;
+			else
+				this_stash_name = psprintf("<stash %d>",
+										   entry->key.pgsa_stash_id);
+		}
+
+		/* Work out tuple values. */
+		values[0] = CStringGetTextDatum(this_stash_name);
+		nulls[0] = false;
+		values[1] = Int64GetDatum(entry->key.queryId);
+		nulls[1] = false;
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+		values[2] = CStringGetTextDatum(advice_string);
+		nulls[2] = false;
+
+		/* Emit the tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to update an advice stash entry for a particular
+ * query ID
+ *
+ * If the second argument is NULL, we delete any existing advice stash
+ * entry; otherwise, we either create an entry or update it with the new
+ * advice string.
+ */
+Datum
+pg_set_stashed_advice(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name;
+	int64		queryId;
+
+	if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+		PG_RETURN_NULL();
+
+	/* Get and check advice stash name. */
+	stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	pgsa_check_stash_name(stash_name);
+
+	/*
+	 * Get and check query ID.
+	 *
+	 * queryID 0 means no query ID was computed, so reject that.
+	 */
+	queryId = PG_GETARG_INT64(1);
+	if (queryId == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("cannot set advice string for query ID 0"));
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Now call the appropriate function to do the real work. */
+	if (PG_ARGISNULL(2))
+		pgsa_clear_advice_string(stash_name, queryId);
+	else
+	{
+		char	   *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+		pgsa_set_advice_string(stash_name, queryId, advice_string);
+	}
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * Get the advice string that has been configured for this query, if any,
+ * and return it. Otherwise, return NULL.
+ */
+static char *
+pgsa_advisor(PlannerGlobal *glob, Query *parse,
+			 const char *query_string, int cursorOptions,
+			 ExplainState *es)
+{
+	pgsa_entry_key key;
+	pgsa_entry *entry;
+	char	   *advice_string;
+	int			stash_id;
+
+	/*
+	 * Exit quickly if the stash name is empty or there's no query ID.
+	 */
+	if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0)
+		return NULL;
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/*
+	 * Translate pg_stash_advice.stash_name to an integer ID.
+	 *
+	 * pgsa_check_stash_name_guc() has already validated the advice stash
+	 * name, so we don't need to call pgsa_check_stash_name() here.
+	 */
+	stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name);
+	if (stash_id == 0)
+		return NULL;
+
+	/*
+	 * Look up the advice string for the given stash ID + query ID.
+	 *
+	 * If we find an advice string, we copy it into the current memory
+	 * context, presumably short-lived, so that we can release the lock on the
+	 * dshash entry. pg_plan_advice only needs the value to remain allocated
+	 * long enough for it to be parsed, so this should be good enough.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = parse->queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		return NULL;
+	if (entry->advice_string == InvalidDsaPointer)
+		advice_string = NULL;
+	else
+		advice_string = pstrdup(dsa_get_address(pgsa_dsa_area,
+												entry->advice_string));
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we found an advice string, emit a debug message. */
+	if (advice_string != NULL)
+		elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s",
+			 pg_stash_advice_stash_name, key.queryId, advice_string);
+
+	return advice_string;
+}
+
+/*
+ * Attach to various structures in dynamic shared memory.
+ *
+ * This function is designed to be resilient against errors. That is, if it
+ * fails partway through, it should be possible to call it again, repeat no
+ * work already completed, and potentially succeed or at least get further if
+ * whatever caused the previous failure has been corrected.
+ */
+static void
+pgsa_attach(void)
+{
+	bool		found;
+	MemoryContext oldcontext;
+
+	/*
+	 * Create a memory context to make sure that any control structures
+	 * allocated in local memory are sufficiently persistent.
+	 */
+	if (pg_stash_advice_mcxt == NULL)
+		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
+													 "pg_stash_advice",
+													 ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
+
+	/* Attach to the fixed-size state object if not already done. */
+	if (pgsa_state == NULL)
+		pgsa_state = GetNamedDSMSegment("pg_stash_advice",
+										sizeof(pgsa_shared_state),
+										pgsa_init_shared_state,
+										&found, NULL);
+
+	/* Attach to the DSA area if not already done. */
+	if (pgsa_dsa_area == NULL)
+	{
+		dsa_handle	area_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		area_handle = pgsa_state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
+			dsa_pin(pgsa_dsa_area);
+			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_dsa_area = dsa_attach(area_handle);
+		}
+		dsa_pin_mapping(pgsa_dsa_area);
+	}
+
+	/* Attach to the stash_name->stash_id hash table if not already done. */
+	if (pgsa_stash_dshash == NULL)
+	{
+		dshash_table_handle stash_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
+		stash_handle = pgsa_state->stash_hash;
+		if (stash_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  NULL);
+			pgsa_state->stash_hash =
+				dshash_get_hash_table_handle(pgsa_stash_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  stash_handle, NULL);
+		}
+	}
+
+	/* Attach to the entry hash table if not already done. */
+	if (pgsa_entry_dshash == NULL)
+	{
+		dshash_table_handle entry_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
+		entry_handle = pgsa_state->entry_hash;
+		if (entry_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  NULL);
+			pgsa_state->entry_hash =
+				dshash_get_hash_table_handle(pgsa_entry_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  entry_handle, NULL);
+		}
+	}
+
+	/* Restore previous memory context. */
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Check whether an advice stash name is legal, and signal an error if not.
+ *
+ * Keep this in sync with pgsa_check_stash_name_guc, below.
+ */
+static void
+pgsa_check_stash_name(char *stash_name)
+{
+	/* Reject empty advice stash name. */
+	if (stash_name[0] == '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name may not be zero length"));
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash names may not be longer than %d bytes",
+					   NAMEDATALEN - 1));
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must not contain non-ASCII characters"));
+}
+
+/*
+ * As above, but for the GUC check_hook. We allow the empty string here,
+ * though, as equivalent to disabling the feature.
+ */
+static bool
+pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source)
+{
+	char	   *stash_name = *newval;
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash names may not be longer than %d bytes",
+							NAMEDATALEN - 1);
+		return false;
+	}
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must not contain non-ASCII characters");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Create an advice stash.
+ */
+static void
+pgsa_create_stash(char *stash_name)
+{
+	pgsa_stash *stash;
+	bool		found;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Create a stash with this name, unless one already exists. */
+	stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found);
+	if (found)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" already exists", stash_name));
+	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+}
+
+/*
+ * Remove any stored advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_clear_advice_string(char *stash_name, int64 queryId)
+{
+	pgsa_entry *entry;
+	pgsa_entry_key key;
+	int			stash_id;
+	dsa_pointer old_dp;
+
+	/* Translate the stash name to an integer ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/*
+	 * Look for an existing entry, and free it. But, be sure to save the
+	 * pointer to the associated advice string, if any.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		old_dp = InvalidDsaPointer;
+	else
+	{
+		old_dp = entry->advice_string;
+		dshash_delete_entry(pgsa_entry_dshash, entry);
+	}
+
+	/* Now we free the advice string as well, if there was one. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
+
+/*
+ * Drop an advice stash.
+ */
+static void
+pgsa_drop_stash(char *stash_name)
+{
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	dshash_seq_status iterator;
+	int			stash_id;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove the entry for this advice stash. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, true);
+	if (stash == NULL)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+	stash_id = stash->pgsa_stash_id;
+	dshash_delete_entry(pgsa_stash_dshash, stash);
+
+	/*
+	 * It should now be impossible for any new entries to be added for the
+	 * advice stash we just deleted. Go through and clean out all the existing
+	 * ones.
+	 */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		if (stash_id == entry->key.pgsa_stash_id)
+		{
+			if (entry->advice_string != InvalidDsaPointer)
+				dsa_free(pgsa_dsa_area, entry->advice_string);
+			dshash_delete_current(&iterator);
+		}
+	}
+	dshash_seq_term(&iterator);
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgsa_init_shared_state(void *ptr, void *arg)
+{
+	pgsa_shared_state *state = (pgsa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_stash_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
+	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
+	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
+	state->next_stash_id = 1;
+	state->area = DSA_HANDLE_INVALID;
+	state->stash_hash = DSHASH_HANDLE_INVALID;
+	state->entry_hash = DSHASH_HANDLE_INVALID;
+}
+
+/*
+ * Look up the integer ID that corresponds to the given stash name.
+ *
+ * Returns 0 if no such stash exists.
+ */
+static int
+pgsa_lookup_stash_id(char *stash_name)
+{
+	pgsa_stash *stash;
+	int			stash_id;
+
+	/* Search the shared hash table. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, false);
+	if (stash == NULL)
+		return 0;
+	stash_id = stash->pgsa_stash_id;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	return stash_id;
+}
+
+/*
+ * Store a new or updated advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
+{
+	pgsa_entry *entry;
+	bool		found;
+	pgsa_entry_key key;
+	int			stash_id;
+	dsa_pointer new_dp;
+	dsa_pointer old_dp;
+
+	/* Translate the stash name to an integer ID. */
+restart:
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/* Make sure that an entry exists. */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find_or_insert(pgsa_entry_dshash, &key, &found);
+	if (!found)
+		entry->advice_string = InvalidDsaPointer;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/*
+	 * Copy the advice string into dynamic shared memory.
+	 *
+	 * If we fail after this point, we'll have a server-lifespan memory leak.
+	 * We assume that, having created the entry above, we'll be able to find
+	 * it again without an error.
+	 */
+	new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1);
+	strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string);
+
+	/*
+	 * Refind the entry and swap the new pointer into place.
+	 *
+	 * If the entry has been deleted since we found or created it above, free
+	 * memory and retry from the top.
+	 */
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+	{
+		dsa_free(pgsa_dsa_area, new_dp);
+		goto restart;
+	}
+	old_dp = entry->advice_string;
+	entry->advice_string = new_dp;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we replaced an old advice string, free it. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control
new file mode 100644
index 00000000000..4a0fff5c866
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.control
@@ -0,0 +1,5 @@
+# pg_stash_advice extension
+comment = 'store and automatically apply plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_stash_advice'
+relocatable = true
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
new file mode 100644
index 00000000000..aed2d2a5a9a
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
@@ -0,0 +1,130 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+SET pg_stash_advice.stash_name = 'regress_stash';
+
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('regress_empty_stash');
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index 2ab6fafbab1..8f09d728698 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -160,6 +160,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 407ff3abffe..8c14bab84e9 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -144,6 +144,7 @@
 <!ENTITY oid2name        SYSTEM "oid2name.sgml">
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
+<!ENTITY pgstashadvice   SYSTEM "pgstashadvice.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
 <!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
new file mode 100644
index 00000000000..089fc66446f
--- /dev/null
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -0,0 +1,218 @@
+<!-- doc/src/sgml/pgstashadvice.sgml -->
+
+<sect1 id="pgstashadvice" xreflabel="pg_stash_advice">
+ <title>pg_stash_advice &mdash; store and automatically apply plan advice</title>
+
+ <indexterm zone="pgstashadvice">
+  <primary>pg_stash_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_stash_advice</filename> extension allows you to stash
+  <link linkend="pgplanadvice">plan advice</link> strings in dynamic
+  shared memory where they can be automatically applied. An
+  <literal>advice stash</literal> is a mapping from
+  <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
+  strings. Whenever a session is asked to plan a query whose query ID appears
+  in the relevant advice stash, the plan advice string is automatically applied
+  to guide planning. Note that advice stashes exist purely in memory. This
+  means both that it is important to be mindful of memory consumption when
+  deciding how much plan advice to stash, and also that advice stashes must
+  be recreated and repopulated whenever the server is restarted.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
+  one database, so that you have access to the SQL functions to manage
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
+  module to be loaded in all sessions where you want this module to
+  automatically apply advice. It will usually be best to do this by adding
+  <literal>pg_stash_advice</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> and restarting the server.
+ </para>
+
+ <para>
+  Once you have met the above criteria, you can create advice stashes
+  using the <literal>pg_create_advice_stash</literal> function described
+  below and set the plan advice for a given query ID in a given stash using
+  the <literal>pg_set_stashed_advice</literal> function. Then, you need
+  only configure <literal>pg_stash_advice.stash_name</literal> to point
+  to the chosen advice stash name. For some use cases, rather than setting
+  this on a system-wide basis, you may find it helpful to use
+  <literal>ALTER DATABASE ... SET</literal> or
+  <literal>ALTER ROLE ... SET</literal> to configure values that will apply
+  only to a database or only to a certain role. Likewise, it may sometimes
+  be better to set the stash name in a particular session using
+  <literal>SET</literal>.
+ </para>
+
+ <para>
+  Because <literal>pg_stash_advice</literal> works on the basis of query
+  identifiers, you will need to determine the query identifier for each query
+  whose plan you wish to control. You will also need to determine the advice
+  string that you wish to store for each query. One way to do this is to use
+  <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
+  show the query ID, and the <literal>PLAN_ADVICE</literal> option will
+  show plan advice. <xref linkend="pgcollectadvice" /> can be used to
+  obtain this information for an entire workload, although care must be
+  taken since it can use up a lot of memory very quickly. Query identifiers can
+  also be obtained through tools such as <xref linkend="pgstatstatements" />
+  or <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
+  will not provide plan advice strings. Note that
+  <xref linkend="guc-compute-query-id" /> must be enabled for query
+  identifiers to be computed; if set to <literal>auto</literal>, loading
+  <literal>pg_stash_advice</literal> will enable it automatically.
+ </para>
+
+ <para>
+  Generally, the fact that the planner is able to change query plans as
+  the underlying distribution of data changes is a feature, not a bug.
+  Moreover, applying plan advice can have a noticeable performance cost even
+  when it does not result in a change to the query plan. Therefore, it is
+  a good idea to use this feature only when and to the extent needed.
+  Plan advice strings can be trimmed down to mention only those aspects
+  of the plan that need to be controlled, and used only for queries where
+  there is believed to be a significant risk of planner error.
+ </para>
+
+ <para>
+  Note that <literal>pg_stash_advice</literal> currently lacks a sophisticated
+  security model. Only the superuser, or a user to whom the superuser has
+  granted <literal>EXECUTE</literal> permission on the relevant functions,
+  may create advice stashes or alter their contents, but any user may set
+  <literal>pg_stash_advice.stash_name</literal> for their session, and this
+  may reveal the contents of any advice stash with that name. Users should
+  assume that information embedded in stashed advice strings may become visible
+  to nonprivileged users.
+ </para>
+
+ <sect2 id="pgstashadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_create_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_create_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Creates a new, empty advice stash with the given name.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_drop_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_drop_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Drops the named advice stash and all of its entries.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_set_stashed_advice(stash_name text, query_id bigint,
+       advice_string text) returns void</function>
+     <indexterm>
+      <primary>pg_set_stashed_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Stores an advice string in the named advice stash, associated with
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
+      <parameter>advice_string</parameter> is <literal>NULL</literal>,
+      any existing entry for that query identifier is removed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stashes() returns setof (stash_name text,
+       num_entries bigint)</function>
+     <indexterm>
+      <primary>pg_get_advice_stashes</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each advice stash, showing the stash name and
+      the number of entries it contains.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stash_contents(stash_name text) returns setof
+       (stash_name text, query_id bigint, advice_string text)</function>
+     <indexterm>
+      <primary>pg_get_advice_stash_contents</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each entry in the named advice stash. If
+      <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
+      entries from all stashes.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.stash_name</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the name of the advice stash to consult during query
+      planning. The default value is the empty string, which disables
+      this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index f8362a78191..30882a1991c 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4027,6 +4027,12 @@ pgpa_trove_lookup_type
 pgpa_trove_result
 pgpa_trove_slice
 pgpa_unrolled_join
+pgsa_entry
+pgsa_entry_key
+pgsa_shared_state
+pgsa_stash
+pgsa_stash_count
+pgsa_stash_name
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-12 23:45  Alexandra Wang <[email protected]>
  parent: Robert Haas <[email protected]>
  4 siblings, 1 reply; 133+ messages in thread

From: Alexandra Wang @ 2026-03-12 23:45 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

Hi Robert,

On Thu, Mar 12, 2026 at 10:16 AM Robert Haas <[email protected]> wrote:
> I'm still hoping to get some more feedback on the remaining patches,
> which are much smaller and conceptually simpler. While there is no
> time to redesign them at this point in the release cycle, there is
> still the opportunity to fix bugs, or decide that they're too
> half-baked to ship. So here is v20 with just those patches. Of course,
> post-commit review of the main patch is also very welcome.

Thanks for the patches!

I've run the meson tests for all three modules, and they all pass on
my laptop.

For the make tests, I had to add

+EXTRA_INSTALL = contrib/pg_plan_advice

in contrib/pg_collect_advice/Makefile in order for "make check" for
that module to run.

I don't really have a sense of how others feel about including these
modules, so I can't speak to that. Personally, though, I very much
like the test_plan_advice module because it gives me peace of mind,
and I feel it should accompany the already committed pg_plan_advice
module.

I reviewed the code and have a few minor comments:

0001:
The "make check" issue mentioned above.

0002:
Looks good to me.

0003:
There is a typo in the commit message:
"pg_stash.advice_stash" should be "pg_stash_advice.stash_name".

> stash->pgsa_stash_id = pgsa_state->next_stash_id++;

I doubt there will be more than 2 billion stashes in the system, but
if in any case we reach that number, we don't handle int overflow.
Should we set a limit on how many stashes can be stored?

Nit:
find_defelem_by_defname() is defined in all three modules, and also in
pg_plan_advice. Knowing it is very small, would it make sense to
extern the one in pg_plan_advice and reuse it?

Best,
Alex

-- 
Alexandra Wang
EDB: https://www.enterprisedb.com


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-13 08:38  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  4 siblings, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-03-13 08:38 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

--00000000000012e49b064ce3cde2
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable

Hi Robert,

On Thu, Mar 12, 2026 at 10:15=E2=80=AFAM Robert Haas <[email protected]=
> wrote:
> I've now committed the main patch. I think that I'm not likely to get
> too much more feedback on that in this release cycle, and I judge that
> more people will be sad if it doesn't get shipped this cycle than if
> it does. That might turn out to be catastrophically wrong, but if many
> problem reports quickly begin to emerge, it can always be reverted.

I think its good to get this in, and not do it in the last minute, but
just to be clear on my part, I've reviewed earlier versions, but I
have not reviewed the latest code to the extent I would have liked
before it was committed, due to competing priorities on my end. That
said, its a good forcing function, so let me do my part to help shake
out any bugs that remain.



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-14 12:00  Alexander Lakhin <[email protected]>
  parent: Robert Haas <[email protected]>
  4 siblings, 1 reply; 133+ messages in thread

From: Alexander Lakhin @ 2026-03-14 12:00 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; David G. Johnston <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

Hello Robert,

12.03.2026 19:15, Robert Haas wrote:
> I've now committed the main patch. I think that I'm not likely to get
> too much more feedback on that in this release cycle, and I judge that
> more people will be sad if it doesn't get shipped this cycle than if
> it does. That might turn out to be catastrophically wrong, but if many
> problem reports quickly begin to emerge, it can always be reverted.
> I'm hopeful my testing has been thorough enough to avoid that, but
> there's a big difference between lab-testing and real world usage, so
> we'll see.

I've found a crash inside pgpa_join_path_setup(), reproduced with:
echo "geqo_threshold = 2" >/tmp/extra.config
TEMP_CONFIG=/tmp/extra.config make -s check -C contrib/pg_plan_advice/

Program terminated with signal SIGSEGV, Segmentation fault.
(gdb) bt
#0  0x000070820b96d215 in pgpa_join_path_setup (root=0x5ef5a84682b8, joinrel=0x5ef5a8497f10, outerrel=0x5ef5a8472b48,
     innerrel=0x5ef5a84baeb8, jointype=JOIN_UNIQUE_INNER, extra=0x7fff08823490) at pgpa_planner.c:602
#1  0x00005ef57df9742b in add_paths_to_joinrel (root=0x5ef5a84682b8, joinrel=0x5ef5a8497f10, outerrel=0x5ef5a8472b48,
     innerrel=0x5ef5a84baeb8, jointype=JOIN_UNIQUE_INNER, sjinfo=0x5ef5a8473c00, restrictlist=0x5ef5a8498450) at 
joinpath.c:178
#2  0x00005ef57df9d178 in populate_joinrel_with_paths (root=0x5ef5a84682b8, rel1=0x5ef5a8472b48, rel2=0x5ef5a84731f0,
     joinrel=0x5ef5a8497f10, sjinfo=0x5ef5a8473c00, restrictlist=0x5ef5a8498450) at joinrels.c:1197
#3  0x00005ef57df9c6ab in make_join_rel (root=0x5ef5a84682b8, rel1=0x5ef5a8472b48, rel2=0x5ef5a84731f0) at joinrels.c:774
#4  0x00005ef57df700be in merge_clump (root=0x5ef5a84682b8, clumps=0x5ef5a8497e60, new_clump=0x5ef5a8497eb0, num_gene=2,
     force=false) at geqo_eval.c:259
#5  0x00005ef57df6ff3e in gimme_tree (root=0x5ef5a84682b8, tour=0x5ef5a84ba688, num_gene=2) at geqo_eval.c:198
#6  0x00005ef57df6fe12 in geqo_eval (root=0x5ef5a84682b8, tour=0x5ef5a84ba688, num_gene=2) at geqo_eval.c:101
#7  0x00005ef57df708fa in random_init_pool (root=0x5ef5a84682b8, pool=0x5ef5a84c3988) at geqo_pool.c:108
#8  0x00005ef57df70439 in geqo (root=0x5ef5a84682b8, number_of_rels=2, initial_rels=0x5ef5a84ba1f8) at geqo_main.c:127
#9  0x00005ef57df77aa3 in make_rel_from_joinlist (root=0x5ef5a84682b8, joinlist=0x5ef5a8473b40) at allpaths.c:3902
#10 0x00005ef57df715e2 in make_one_rel (root=0x5ef5a84682b8, joinlist=0x5ef5a8473b40) at allpaths.c:240
#11 0x00005ef57dfbede4 in query_planner (root=0x5ef5a84682b8, qp_callback=0x5ef57dfc5ae9 <standard_qp_callback>,
     qp_extra=0x7fff08823af0) at planmain.c:297
...

Could please look at this?

Best regards,
Alexander

^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-14 15:06  Zsolt Parragi <[email protected]>
  parent: Robert Haas <[email protected]>
  4 siblings, 1 reply; 133+ messages in thread

From: Zsolt Parragi @ 2026-03-14 15:06 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

Hello

I noticed a difference between installed headers with make and meson,
which is caused by pg_plan_advice.h. It is completely missing from the
make build, and installs to the wrong location with meson.

Please see the attached patch that fixes this.


Attachments:

  [application/octet-stream] 0001-Fix-pg_plan_advice-header-install-discrepancy-betwee.patch (1.5K, 2-0001-Fix-pg_plan_advice-header-install-discrepancy-betwee.patch)
  download | inline diff:
From f72d760b8746fc8b57c6641dd5a799d6a84e76d3 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <[email protected]>
Date: Sat, 14 Mar 2026 14:29:49 +0000
Subject: [PATCH] Fix pg_plan_advice header install discrepancy between make
 and meson

The Makefile was missing header installation entirely, and the meson
build was installing to extension/ despite pg_plan_advice not having
a control file.

Fix both: add HEADERS_pg_plan_advice to the Makefile, and change meson
to install to contrib/ to match.
---
 contrib/pg_plan_advice/Makefile    | 2 ++
 contrib/pg_plan_advice/meson.build | 2 +-
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
index d2a8233f387..cd478dc1a6d 100644
--- a/contrib/pg_plan_advice/Makefile
+++ b/contrib/pg_plan_advice/Makefile
@@ -15,6 +15,8 @@ OBJS = \
 	pgpa_trove.o \
 	pgpa_walker.o
 
+HEADERS_pg_plan_advice = pg_plan_advice.h
+
 PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
 
 REGRESS = gather join_order join_strategy partitionwise prepared \
diff --git a/contrib/pg_plan_advice/meson.build b/contrib/pg_plan_advice/meson.build
index cf948ffaa13..36bbc4e9826 100644
--- a/contrib/pg_plan_advice/meson.build
+++ b/contrib/pg_plan_advice/meson.build
@@ -44,7 +44,7 @@ contrib_targets += pg_plan_advice
 
 install_headers(
   'pg_plan_advice.h',
-  install_dir: dir_include_extension / 'pg_plan_advice',
+  install_dir: dir_include_server / 'contrib' / 'pg_plan_advice',
 )
 
 tests += {
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-16 16:52  Robert Haas <[email protected]>
  parent: Zsolt Parragi <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-16 16:52 UTC (permalink / raw)
  To: Zsolt Parragi <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sat, Mar 14, 2026 at 11:06 AM Zsolt Parragi
<[email protected]> wrote:
> I noticed a difference between installed headers with make and meson,
> which is caused by pg_plan_advice.h. It is completely missing from the
> make build, and installs to the wrong location with meson.
>
> Please see the attached patch that fixes this.

Thanks. The changes to the Makefile seem to mirror what is done in
contrib/isn/Makefile, but I'm not so sure about the meson.build
changes. sepgsql uses dir_data / 'contrib' rather than
dir_include_server. src/pl/pl{perl,pgsql,python} use
dir_include_server, but they also live in src/pl, not contrib. I don't
think I understand what the underlying principal is supposed to be
here. If you or anyone else knows, please enlighten me.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-16 17:11  Robert Haas <[email protected]>
  parent: Alexander Lakhin <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-16 17:11 UTC (permalink / raw)
  To: Alexander Lakhin <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sat, Mar 14, 2026 at 8:00 AM Alexander Lakhin <[email protected]> wrote:
> I've found a crash inside pgpa_join_path_setup(), reproduced with:
> echo "geqo_threshold = 2" >/tmp/extra.config
> TEMP_CONFIG=/tmp/extra.config make -s check -C contrib/pg_plan_advice/
> ...
>
> Could please look at this?

Thanks. Proposed fix attached.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] 0001-pg_plan_advice-Avoid-a-crash-under-GEQO.nocfbot (2.6K, 2-0001-pg_plan_advice-Avoid-a-crash-under-GEQO.nocfbot)
  download

^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-16 17:34  Robert Haas <[email protected]>
  parent: Alexandra Wang <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-16 17:34 UTC (permalink / raw)
  To: Alexandra Wang <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 12, 2026 at 7:46 PM Alexandra Wang
<[email protected]> wrote:
> For the make tests, I had to add
>
> +EXTRA_INSTALL = contrib/pg_plan_advice

Thanks, will fix.

> There is a typo in the commit message:
> "pg_stash.advice_stash" should be "pg_stash_advice.stash_name".

Thanks, will fix.

> I doubt there will be more than 2 billion stashes in the system, but
> if in any case we reach that number, we don't handle int overflow.
> Should we set a limit on how many stashes can be stored?

I think the thing to do here is just change the stash ID to a 64-bit
value. I'll do that in the next version. We assume in numerous places
that a 64-bit integer never overflows (e.g. LSNs) which is pretty fair
considering how long it would take for that to happen.

> Nit:
> find_defelem_by_defname() is defined in all three modules, and also in
> pg_plan_advice. Knowing it is very small, would it make sense to
> extern the one in pg_plan_advice and reuse it?

I think if we want to avoid duplicating this function, we should
actually put it someplace in core, e.g. expose it via defrem.h and put
the code in commands/define.c. Any user of extendplan.h is likely to
need a function like this, but we shouldn't put it there because there
could be other use cases as well.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-16 20:51  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-16 20:51 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Mar 13, 2026 at 4:39 AM Lukas Fittl <[email protected]> wrote:
> From a code review perspective, I found some minor issues:
>
> - Identifiers that are named like advice types cause an error (so I
> can't name my table "hash_join")

Thanks, I extracted and committed your fix for that issue.

> - pgpa_destroy_join_unroller does not free the inner_unrollers values
> (which I think goes against the intent of cleaning up the allocations
> right away)

Yeah, that makes sense.

> - pgpa_parser uses pgpa_yyerror, but that function doesn't use
> elog(ERROR) like the main parser does - I think it'd be more
> consistent to use YYABORT explicitly, e.g. how we do it in cubeparse

Perhaps, but I think this needs more thought, because you haven't done
anything about the calls in pgpa_scanner.l. I think right now, the
operating principle is that parsing continues after an error and that
we mustn't crash as a result, though the resulting tree will probably
be invalid for practical use. If we're going to change that to stop on
the spot, I think we should probably do it for both lexing and
parsing, and think about whether that leads to any other changes or
simplificatons.

> - pgpa_scanner accepts integers with underscores, but incorrectly uses
> a simple strtoint on them (which would fail), instead of pg_strtoint32
> / pg_strtoint32_safe

OK.

> - pgpa_walk_recursively uses "0" for the boolean value being passed to
> a recursive call in one case (should be "false")

OK.

> From a usability perspective, I do wonder about two things when it
> comes to users specifying advice directly (which will happen in
> practice, e.g. when debugging plan problems):
>
> 1) The lack of being able to target scan advice (e.g. SEQ_SCAN) to a
> partition parent is frustrating - I believe we discussed partitioning
> earlier in the thread in terms of gathering+applying it, but I do
> wonder if we shouldn't at least allow users to specify a partitioned
> table/partitioned index, instead of only the children. See attached
> nocfbot-0002 for an idea what would be enough, I think.

I'm not on board with this without a lot more study. I've been down
this road before, and it can easily end in tears. Examining the
partition structure on the fly can have a performance cost, and it
might even have security ramifications or at least bugs if there are
concurrent modifications to the partitioning structure happening.
Also, the test_plan_advice framework doesn't do much to tell us
whether this actually works. Also, I understand the frustration and
I'm sure we'll want to introduce various forms of wildcards, but I
think there will be a lot of opinions about that should actually look
like. One can, as you've done here, follow index links from child to
parent. One can do a wildcard match on the index name. One could want
to specify an index on a particular column rather than a specific
name, to survive index renamings. I wouldn't be surprised if there are
other ideas, too. Three weeks before feature freeze is not the time to
be taking an opinionated position on what the best answers will
ultimately turn out to be. It's easy to write a tool that will spit
out matching index advice for all indexes involving in a partitioning
hierarchy, and I think that's what people should do for now. Or,
perhaps they should use the generated advice from actual plans instead
of writing hand-crafted advice.

We always have the option to add more to this in the future, but
taking things out is not half so easy.

> 2) I find the join strategy advice hard to use - pg_hint_plan has
> hints (e.g. HashJoin) that allow saying "use a hash join when joining
> these two rels in any order", vs pg_plan_advice requires setting a
> specific outer rel, which only makes sense when you want to fully
> specify every aspect of the plan. I suspect users who directly write
> advice will struggle with specifying join strategy advice as it is
> right now. We could consider having a different syntax for saying "I
> want a hash join when these two rels are joined, but I don't care
> about the order", e.g. "USE_HASH_JOIN(a b)". If you think that's
> worthwhile I'd be happy to take a stab at a patch.

I'd be inclined to classify that as a design error in pg_hint_plan,
but maybe I'm just not understanding something. Under what
circumstances would you know that you wanted two tables to be joined
via a hash join but not care which one was on which side of the join?
The rels on the two sides are treated very differently. One of them
needs to be small enough to fit in the hash table and should be one
where repeated index lookups aren't better (else, you should be
advising a nested loop and an index scan). The other can be basically
anything. What I know (in a situation where I might write some advice
manually) is that I want a certain table to be the one that goes into
the hash table, not that there should be a hash join someplace in the
general vicinity of one of the tables.

Also, there's a definitional question here. What exactly does
USE_HASH_JOIN(a b) mean? Possible definitions:

1. "a" must be joined directly to "b" without any other tables on
either side, and a hash join must be used to perform that join.
2. either "a" must appear on the inner side of a hash join with "b"
somewhere on the other side, possibly accompanied by other tables, or
the reverse
3. the plan must contain at least one hash join where "a" and "b"
appear on opposite sides of the join

Suppose I have a fact table f and three dimension tables d1, d2, and
d3. If I wrote USE_HASH_JOIN(f d1) USE_HASH_JOIN(f d2) USE_HASH_JOIN(f
d3), what happens? Under definition 1, the advice fails, because f
must be first joined to either d1, d2, or d3, and whichever one is
chosen, the other advice can't now be satisfied. Under definition 2, I
will definitely end up with hash joins all the way through, but it's
possible that the driving table will be one of the dimension tables,
which can be first joined to f and then to the other tables. Under
definition 3, I'm not even guaranteed to end up with all hash joins: I
can join d1, d2, and d3 to each other in any way I like, and then hash
join the result of that to f in either direction, and the rule is
satisfied for all three advice items.

None of those possibilities sound right. I argue that definitions 1
and 3 produce such absurd results in this scenario that they're not
even worth any further consideration, but I can see someone arguing
that definition 2 doesn't sound so bad. After all, you could still fix
it to achieve the probably-expected outcome by also writing
JOIN_ORDER(f). But that only works here because we know what we want
the driving table to me. Suppose alternatively that we have two large
tables B1 and B2 and a small table S, and we've figured out that the
planner tends to like to use the index on table S when it really ought
to be using a hash join, but we trust its judgement as to how to join
B1 and B2. With HASH_JOIN() as I've implemented it, that's easy: just
write HASH_JOIN(S). With your USE_HASH_JOIN, how do I do that exactly?
If I write USE_HASH_JOIN(B1 S), definition 2 permits a plan like this:

Hash Join
  -> Nested Loop
    -> Seq Scan on B2
    -> Index Scan on S
  -> Hash
    -> Seq Scan on B1

To me, that looks a heck of a lot like my USE_HASH_JOIN() locution
just didn't do anything. It certainly didn't do what I intended it to
do, and the only way I can make sure that something like this doesn't
happen is to *also* constrain the join order. If I'm willing to decide
which of B1 and B2 should be the driving table, then I can write
JOIN_ORDER(B1 B2 S) or JOIN_ORDER(B2 B1 S) and now everything is
fixed. But this seems 100% backwards given your stated goal: you want
to be able to constraint the join method without constraining the join
order. As I see it, the problem here is that a symmetric USE_HASH_JOIN
directive either uses something like definition 1 which is too tight a
constraint, or definition 2 or 3 which are extremely weak constraints
that essentially allow the planner to satisfy the constraint using
some other part of the plan tree.

Now, of course, I got to pick these examples, so I picked examples
that prove my point. Maybe there are examples where a "one side or the
other" constraint actually works better. But I don't know what those
examples are. When I've experimented this kind of thing, I've found
that I never get the results that I want because the planner just does
something stupid that technically satisfies the constraint but is
nothing like what I actually meant. If you know of examples where my
definitions suck and the "one side or the other" definition produces
great results, I'd love to hear about them ... although I would have
loved to hear about them even more 4.5 months ago when I first posted
this patch set and already had the phrase "useless in practice" in the
README on exactly this topic. This is exactly why I put the patches up
for design review before they were fully baked.

> For v20-0001, from a quick conceptual review:
>
> I find the two separate GUC mechanisms for local backend vs shared
> memory a bit confusing as a user (which one should I be using?).
> Enabling the shared memory mechanism on a system-wide basis seems like
> it'd likely have too high overhead anyway for production systems?
> (specifically worried about the advice capturing for each plan, though
> I haven't benchmarked it)
>
> I wonder if we shouldn't keep this simpler for now, and e.g. only do
> the backend local version to start - we could iterate a bit on
> system-wide collection out-of-core, e.g. I'm considering teaching
> pg_stat_plans to optionally collect plan advice the first time it sees
> a plan ID (plan advice is roughly a superset of what we consider a
> plan ID there), and then we could come back to this for PG20.

The shared version is rather useful for testing, though. That's
actually why I created it initially: turn on the shared collector, run
the regression tests, and then use SQL to look through the collector
results for interesting things. You can't do that with the local
collector.

> To help assess impact, I did a quick test run and looked at three
> not-yet-committed patches in the commitfest that affect planner logic
> ([0], [1] and [2]), to see if they'd require pg_plan_advice changes
> (master with v20-0002 applied). Maybe I picked the wrong patches, but
> at least with those no pg_plan_advice changes were needed with the
> test_plan_advice test enabled.

Nice.

> On the code itself:
> - Is there a reason you're setting
> "pg_plan_advice.always_store_advice_details" to true, instead of using
> pg_plan_advice_request_advice_generation?
> - I wonder if we could somehow detect advice not matching? Right now
> that'd be silently ignored, i.e. you'd only get a test failure when we
> generate the wrong advice that causes a plan change in the regression
> tests.

I think it already does more than what you seem to be thinking:
test_plan_advice checks the advice feedback, too. However, it's also
true that even more could be done. The code proposed here checks that
all advice is /* matched */ without being /* failed */ or /*
inapplicable */, but I have code locally that checks the reverse,
namely that every decision that, during the re-plan, every decision
could have been constrained by advice actually was. I felt it was a
bit too late to add that to what I was submitting for v19, but that
decision could certainly be revisited. Taking it even further, we
could do structural comparisons of the before-and-after plans, or we
could search for disabled nodes aside from what default_pgs_mask
should imply. I'm not very confident that those things are worth the
code they would take to implement, though. The existing checks found a
lot of bugs, but I think whatever is still wrong is probably not
super-likely to be found by additional cross-checks. That could be
wrong; it's just a hunch.

> For v20-0003, initial thoughts:
>
> I think getting at least a basic version of this in would be good, as
> a server-wide way to set advice for queries can help people get out of
> a problem when Postgres behaves badly - and we know from pg_hint_plan
> (which has a hint table) that this can be useful even without doing
> any kind of parameter sniffing/etc to be smart about different
> parameters for the same query.

Yep.

> The name "stash" feels a bit confusing as an end-user facing term.
> Maybe something like "pg_apply_advice", or "pg_query_advice" would be
> better? (though I kind of wish we could tie it more closely to "plan
> advice", but e.g. "pg_plan_advice_apply" feels too lengthy)

I've heard that other people also find "stash" not as intuitive as it
could be, and I'm open to changing it. However, whatever we call this
has to make its relationship to pg_plan_advice clear. If we have
pg_plan_advice and pg_query_advice, which one is the core module and
which one is automatically supplying advice? You can't tell from the
name, which I think will be confusing. In the long run, I suspect we
will end up with a moderately-large pile of tools for either capturing
advice or automatically supplying it, and we should think about how
we'd like all those to get named. Right now, I suppose we might end up
with something like this:

pg_plan_advice: The core. There can only be one.

different ways of capturing advice strings: pg_collect_advice,
pg_capture_advice, pg_save_advice, pg_baseline_advice, ....

different ways of supplying advice strings: pg_stash_advice,
pg_auto_advice, pg_lookup_advice, pg_store_advice,
pg_advice_from_query_comment, ...

I don't love this, because the pattern I see developing here is that
module names will either be very long or they'll just take a word from
an existing module name (like "collect") and replace it with a synonym
(like "capture"). That's not going to produce anything very mnemonic,
so it would be nice to do better. But I think the route to doing
better has to be to get more specific with the naming, not less. Like,
if we renamed pg_stash_advice to
pg_lookup_in_memory_advice_by_query_id, then the name tells you
EXACTLY what it does, which is great. Unfortunately, the name is also
very long, which is why I didn't go that route.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-16 23:25  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-03-16 23:25 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Mar 16, 2026 at 1:51 PM Robert Haas <[email protected]> wrote:
> On Fri, Mar 13, 2026 at 4:39 AM Lukas Fittl <[email protected]> wrote:
> > - pgpa_parser uses pgpa_yyerror, but that function doesn't use
> > elog(ERROR) like the main parser does - I think it'd be more
> > consistent to use YYABORT explicitly, e.g. how we do it in cubeparse
>
> Perhaps, but I think this needs more thought, because you haven't done
> anything about the calls in pgpa_scanner.l. I think right now, the
> operating principle is that parsing continues after an error and that
> we mustn't crash as a result, though the resulting tree will probably
> be invalid for practical use. If we're going to change that to stop on
> the spot, I think we should probably do it for both lexing and
> parsing, and think about whether that leads to any other changes or
> simplificatons.

Fair - I don't think there is a practical impact from not doing the
YYABORT calls, since as you say care is taken to not crash in that
case.

> > From a usability perspective, I do wonder about two things when it
> > comes to users specifying advice directly (which will happen in
> > practice, e.g. when debugging plan problems):
> >
> > 1) The lack of being able to target scan advice (e.g. SEQ_SCAN) to a
> > partition parent is frustrating - I believe we discussed partitioning
> > earlier in the thread in terms of gathering+applying it, but I do
> > wonder if we shouldn't at least allow users to specify a partitioned
> > table/partitioned index, instead of only the children. See attached
> > nocfbot-0002 for an idea what would be enough, I think.
>
> I'm not on board with this without a lot more study. I've been down
> this road before, and it can easily end in tears. Examining the
> partition structure on the fly can have a performance cost, and it
> might even have security ramifications or at least bugs if there are
> concurrent modifications to the partitioning structure happening.
> Also, the test_plan_advice framework doesn't do much to tell us
> whether this actually works. Also, I understand the frustration and
> I'm sure we'll want to introduce various forms of wildcards, but I
> think there will be a lot of opinions about that should actually look
> like. One can, as you've done here, follow index links from child to
> parent. One can do a wildcard match on the index name. One could want
> to specify an index on a particular column rather than a specific
> name, to survive index renamings. I wouldn't be surprised if there are
> other ideas, too. Three weeks before feature freeze is not the time to
> be taking an opinionated position on what the best answers will
> ultimately turn out to be. It's easy to write a tool that will spit
> out matching index advice for all indexes involving in a partitioning
> hierarchy, and I think that's what people should do for now.

That's fair. I would like us to do something about this in the PG20
release cycle - for my part, I think its reasonable to follow the
declarative partitioning parent-child relationship for indexes if
present - assuming we can sort out the performance/etc. aspects of
that.

For 19 I think we might want to consider calling this out more
explicitly in the documentation under the "Scan Method Advice"
paragraph, i.e. that one cannot specify partition parent table names
(at least not ones that have no data of their own) and instead one has
to specify the partitions individually. Otherwise I think users will
just be confused by the Append node that says "Disabled: true" and the
advice that didn't match.

> > 2) I find the join strategy advice hard to use - pg_hint_plan has
> > hints (e.g. HashJoin) that allow saying "use a hash join when joining
> > these two rels in any order", vs pg_plan_advice requires setting a
> > specific outer rel, which only makes sense when you want to fully
> > specify every aspect of the plan. I suspect users who directly write
> > advice will struggle with specifying join strategy advice as it is
> > right now. We could consider having a different syntax for saying "I
> > want a hash join when these two rels are joined, but I don't care
> > about the order", e.g. "USE_HASH_JOIN(a b)". If you think that's
> > worthwhile I'd be happy to take a stab at a patch.
>
> I'd be inclined to classify that as a design error in pg_hint_plan,
> but maybe I'm just not understanding something. Under what
> circumstances would you know that you wanted two tables to be joined
> via a hash join but not care which one was on which side of the join?

I think the common case would be someone sees the planner picked a
Nested Loop, and instead wants to see the plan that prefers a Hash
Join (or Merge Join), e.g. to understand costing differences. That's
how I usually use pg_hint_plan, to understand what the alternate plan
looked like that the planner didn't pick, but where costs were close.
The top-level "enable_nestloop = off" often tends to not work that
well for complex plans, hence my historic use of pg_hint_plan's
HASHJOIN/MERGEJOIN (or NO_NESTLOOP) hints for this purpose.

> Also, there's a definitional question here. What exactly does
> USE_HASH_JOIN(a b) mean? Possible definitions:
>
> ...
>
> Now, of course, I got to pick these examples, so I picked examples
> that prove my point. Maybe there are examples where a "one side or the
> other" constraint actually works better. But I don't know what those
> examples are. When I've experimented this kind of thing, I've found
> that I never get the results that I want because the planner just does
> something stupid that technically satisfies the constraint but is
> nothing like what I actually meant. If you know of examples where my
> definitions suck and the "one side or the other" definition produces
> great results, I'd love to hear about them ...

Thanks for the detailed work through - I think I see your
implementation choice for this more clearly now. I've also re-read the
documentation section on join methods and I think that is clear enough
in terms of how it works.

I don't think a change here is necessary. I think for the use case I
described I will just resort to testing both variants (i.e. being more
specific which shape of plan I want), which I think aligns with the
goals of pg_plan_advice as compared to pg_hint_plan.

Later in the release cycle I'll see if I can put together a community
resource that compares pg_hint_plan to pg_plan_advice, and where they
differ. I suspect many end users will have similar questions, and
whilst I don't think explaining the differences belongs in the regular
Postgres docs, it could fit the wiki as a cheatsheet of sorts.

> although I would have
> loved to hear about them even more 4.5 months ago when I first posted
> this patch set and already had the phrase "useless in practice" in the
> README on exactly this topic. This is exactly why I put the patches up
> for design review before they were fully baked.

Understood - I'll admit I mainly looked at the high-level join logic
before (and the join hooks in detail when doing the pg_hint_plan
testing) but had not fully understood how you dealt with join
hierarchies / specifying them in the advice. I had previously looked
at examples where multiple tables were listed assuming it worked like
hint plan, but that's not the case.

>
> > For v20-0001, from a quick conceptual review:
> >
> > I find the two separate GUC mechanisms for local backend vs shared
> > memory a bit confusing as a user (which one should I be using?).
> > Enabling the shared memory mechanism on a system-wide basis seems like
> > it'd likely have too high overhead anyway for production systems?
> > (specifically worried about the advice capturing for each plan, though
> > I haven't benchmarked it)
> >
> > I wonder if we shouldn't keep this simpler for now, and e.g. only do
> > the backend local version to start - we could iterate a bit on
> > system-wide collection out-of-core, e.g. I'm considering teaching
> > pg_stat_plans to optionally collect plan advice the first time it sees
> > a plan ID (plan advice is roughly a superset of what we consider a
> > plan ID there), and then we could come back to this for PG20.
>
> The shared version is rather useful for testing, though. That's
> actually why I created it initially: turn on the shared collector, run
> the regression tests, and then use SQL to look through the collector
> results for interesting things. You can't do that with the local
> collector.

Right - I can see the usefulness for testing, but I worry that people
use it on production systems and then experience unexpected
performance issues. That said, we could address that with a warning in
the docs noting its not intended for production use.

Out of time for today to think through naming for 0003 more, but I'll
see that I find more time this week.

Thanks,
Lukas

-- 
Lukas Fittl





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-17 05:06  Zsolt Parragi <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Zsolt Parragi @ 2026-03-17 05:06 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

> Thanks. The changes to the Makefile seem to mirror what is done in
> contrib/isn/Makefile, but I'm not so sure about the meson.build
> changes. sepgsql uses dir_data / 'contrib' rather than
> dir_include_server. src/pl/pl{perl,pgsql,python} use
> dir_include_server, but they also live in src/pl, not contrib. I don't
> think I understand what the underlying principal is supposed to be
> here. If you or anyone else knows, please enlighten me.

PGXS defines it as:

#   HEADERS_$(MODULE) -- files to install into
#     $(includedir_server)/$MODULEDIR/$MODULE; the value of $MODULE must be
#     listed in MODULES or MODULE_big

where

#   MODULEDIR -- subdirectory of $PREFIX/share into which DATA and DOCS files
#     should be installed (if not set, default is "extension" if EXTENSION
#     is set, or "contrib" if not)

And I mirrored that in meson.

Data seems to be wrong for headers, as that's

#   DATA -- random files to install into $PREFIX/share/$MODULEDIR

> sepgsql uses dir_data / 'contrib'

Also, sepgsql installs an sql file, not an include.





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-17 13:44  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-17 13:44 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Mar 16, 2026 at 7:25 PM Lukas Fittl <[email protected]> wrote:
> That's fair. I would like us to do something about this in the PG20
> release cycle - for my part, I think its reasonable to follow the
> declarative partitioning parent-child relationship for indexes if
> present - assuming we can sort out the performance/etc. aspects of
> that.

I wonder if instead of doing this as you did it, we should try to make
partition expansion happen around expand_inherited_rtentry time. So
maybe you can write something like PARTITIONED_SEQ_SCAN(x) or
PARTITIONED_INDEX_SCAN(x y) and, internally, that gets replaced with
one SEQ_SCAN(..) or INDEX_SCAN(...) entry per child before the core
planning logic engages. And, during the plan walk, we could do the
reverse transformation: if we computed matching advice items for every
partition, consolidate them down to just one before constructing the
final advice string. If we did that, the whole thing would be
symmetric and we'd have a certain amount of automatic test coverage,
plus we'd shorten a lot of automatically written advice strings
considerably. Maybe this is not better than the more on-the-fly way
that you did it, but I think it's worth some study.

While I'm on the subject, there are some other opportunities for
brevity that I have not pursued for this release. In particular,
NO_GATHER(...) often seems quite tedious to me. We could introduce a
wildcard, like "*", that just means everything, so you could write
NO_GATHER(*) for non-parallel queries. However, that seems like it's
actually not great, because as soon as you have a single Gather or
Gather Merge node in the plan, you're back to needing to write all the
other ones out. So another idea is to let you write something like
ONLY_EXPLICIT_GATHER() as a no-argument incantation that means that
everything that isn't mentioned as a GATHER() or GATHER_MERGE() target
should be considered NO_GATHER(). Or you could call that something
like NO_GATHER_OTHERS() or whatever. Or maybe there could be some
general default facility that lets you say what should happen when
nothing is specified, like DEFAULTS(NO_GATHER), but right now the
number of things that you could apply that to would be quite limited.

> For 19 I think we might want to consider calling this out more
> explicitly in the documentation under the "Scan Method Advice"
> paragraph, i.e. that one cannot specify partition parent table names
> (at least not ones that have no data of their own) and instead one has
> to specify the partitions individually. Otherwise I think users will
> just be confused by the Append node that says "Disabled: true" and the
> advice that didn't match.

We can consider that, but I think the bigger picture here is that
writing advice strings by hand is hard, and that's why EXPLAIN
(PLAN_ADVICE) and pg_collect_advice exist -- to give you a starting
point. I fear that if we try to enumerate a lot of specific examples
of ways in which people might be confused in the documentation, they
won't read it, and they'll still be confused. I think the primary
focus of the documentation should be to get people to use the advice
generation facilities as their main way to discover how to use the
system, and then pointing out specific things that may still be
confusing where it makes sense is also good to do. For example, in a
case like this, if you sit down and write INDEX_SCAN(partitioned_table
partitioned_index), yeah that's not going to work, and you're going to
be confused (potentially). But that's not really what you're supposed
to do. You're supposed to start by running EXPLAIN (PLAN_ADVICE) on
the query whose plan you're trying to manipulate, and if you do that,
you'll see that the generated advice shows a separate INDEX_SCAN() or
SEQ_SCAN() item for each child table, and then it's sorta obvious what
you're supposed to be doing. You may very well not like that -- I
think many, many people are going to complain about it -- but you'll
understand what is possible with the system that we have. Now that is
not to say that I think you're wrong about documenting this, and I've
certainly tried to document some other instances of things that I
found confusing even as the author of the system. However, there's
also a lot of cases that I haven't tried to document because it's just
too much useless, abstract information. On this particular point, if
there's a nice plan to fit this into the documentation that doesn't
feel like a jarring topic shift or a long detour into minor details,
I'm fine with it, but I don't think it's worth a lot of contortions to
fit this specific thing in considering how many other things there
are.

> I think the common case would be someone sees the planner picked a
> Nested Loop, and instead wants to see the plan that prefers a Hash
> Join (or Merge Join), e.g. to understand costing differences. That's
> how I usually use pg_hint_plan, to understand what the alternate plan
> looked like that the planner didn't pick, but where costs were close.
> The top-level "enable_nestloop = off" often tends to not work that
> well for complex plans, hence my historic use of pg_hint_plan's
> HASHJOIN/MERGEJOIN (or NO_NESTLOOP) hints for this purpose.

It's interesting to me that this works well for you even in complex
plans. For example, let's say I have something like this (omitting
sorts):

Merge Join
-> Nested Loop
  -> Hash Join
    -> Seq Scan on A
    -> Hash
      -> Seq Scan on B
  -> Index Scan on C
-> Nested Loop
  -> Seq Scan on D
  -> Index Scan on E

Now, what do you write here to get rid of the nested loop involving C?
Your example before of USE_HASH_JOIN(x y) seemed to imply that you
mentioned two tables, but this is a 3-table join, so are you
mentioning all three tables in this case, like USE_HASH_JOIN(A B C)?
Or are you mentioning just C, or A and C, or what? In pg_plan_advice,
it's just HASH_JOIN(C). I'm unclear what the right answer is in
pg_hint_plan. The documentation says that HashJoin(table table [...])
forces hash join for the joins on the tables specified, but I don't
know whether that means that the whole thing functions as a single
constraint (i.e. the N-way join product of all mentioned tables should
be computed using a HashJoin at the uppermost level) or as a
constraint per table (every mentioned table should be involved in a
hash join). If it's the former, then you could write HashJoin(A B C)
in this case, but that wouldn't preclude switching the join order so
that the A-C join is done first using a Nested Loop and then the join
to B is done afterward as a HashJoin, which is probably not what you
wanted. If it's the latter, then HashJoin(C) is probably good enough,
although not necessarily. If there are join clauses connecting B and
C, the planner could try to cheat by doing a hash join between B and C
first and then doing a Nested Loop join to A afterwards, which is also
probably not what you wanted, but typically there won't be such join
clauses so it will work out. Also, if the HashJoin(C) just means there
has to be a hash join somewhere above C, rather than that C has to be
on onside or the other of a hash join by itself, then the planner
could also cheat by switching the outer merge join to a hash join, but
I'm guessing that probably isn't what it means. But if that's the
case, then what would you write you did want to replace the outer
merge join with a hash join? Both sides have more than one table, so
if HashJoin(x) means x has to appear alone on one side of the join,
there would be no way to get what you want here. I wish the actual
behavior of pg_hint_plan were better-documented here, and I'd
appreciate an explanation of how you use it in a case like this and
what actually happens.

But all that having been said, I do think there's space for softer
constraints than what pg_plan_advice currently offers. For example,
there's currently no way to say "I'd like an index scan but I don't
care which index you use". I've been thinking we could invent
ANY_INDEX_SCAN() and ANY_INDEX_ONLY_SCAN() for that purpose at some
point, or maybe people would prefer negative constraints instead, like
NO_SEQ_SCAN(). And maybe your idea of either-way join method
constraints falls in that category too. It's easiest for me to imagine
someone wanting that for merge join, where the two sides are treated
more nearly symmetrically. But I think we would need to nail down
exactly what the semantics of that are. Given that we've got a
sublists available as a tool, we could define a "symmetric join method
request" to take two arguments where the first is the relation
identifiers, or a list of all the relation identifiers, that appear on
one side of the join, and the same for the other side of the join. So
in the example above, if you wanted to replace the nested loop with a
hash join in one direction or the other, you could write
FLIPFLOPPY_HASH_JOIN((A B) C), and if you wanted to replace the outer
merge join, you could write FLIPFLOPPY_HASH_JOIN((A B C) (D E)). I'm
not altogether convinced that's better than just writing HASH_JOIN(C)
or HASH_JOIN((D E)), and there's some definitely user and code
complexity to supporting both methods, but maybe it will turn out to
be the right thing.

(It also occurs to me that the proposed semantics of
FLIPFLOPPY_HASH_JOIN are actually both stronger and weaker than the
existing HASH_JOIN directive. It's weaker in that the sides of the
join can be switched, but it's stronger in that it constrains what has
to be on both sides of the join, whereas HASH_JOIN does not do that.
Of course, as is hopefully clear by now, this is not the only possible
set of semantics that we could choose to implement here: things that
seem simple when you think about 2-table cases are often not simple at
all when scaled up to more complex situations. More than anything
else, I want whatever we implement to be extremely well-defined, with
absolutely no room for debate about what a given advice tag does or
whether a certain plan complies with a certain advice item.)

> Later in the release cycle I'll see if I can put together a community
> resource that compares pg_hint_plan to pg_plan_advice, and where they
> differ. I suspect many end users will have similar questions, and
> whilst I don't think explaining the differences belongs in the regular
> Postgres docs, it could fit the wiki as a cheatsheet of sorts.

Yeah, that sounds nice.

> Right - I can see the usefulness for testing, but I worry that people
> use it on production systems and then experience unexpected
> performance issues. That said, we could address that with a warning in
> the docs noting its not intended for production use.

I think it depends a lot on what you mean by "production use". I think
it's definitely true that pg_collect_advice is not intended for
continuous advice collection. If you try to use it that way, you'll
either start throwing away entries you wanted to keep (if the
collection limit is low) or you'll blow out memory (if the collection
limit is high). But it's completely reasonable to enable it on a
production server in a controlled way, to collect all the queries and
advice strings for one representative transaction of each type (or
even 10 or 50 representative transactions of each type). So I feel
like this is about understanding how it's intended to be used, and the
answer is definitely not "just like pg_stat_statements!".

BTW, I wonder if it would be worth considering, obviously for next
release cycle rather than this one, extending pg_stat_statements to
have the ability to grab plan advice as well, rather than building the
query normalization and deduplication features that pg_stat_statements
already has into pg_collect_advice.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-17 17:44  Robert Haas <[email protected]>
  parent: Zsolt Parragi <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-17 17:44 UTC (permalink / raw)
  To: Zsolt Parragi <[email protected]>; +Cc: David G. Johnston <[email protected]>; PostgreSQL Hackers <[email protected]>

On Tue, Mar 17, 2026 at 1:06 AM Zsolt Parragi <[email protected]> wrote:
> PGXS defines it as:
>
> #   HEADERS_$(MODULE) -- files to install into
> #     $(includedir_server)/$MODULEDIR/$MODULE; the value of $MODULE must be
> #     listed in MODULES or MODULE_big
>
> where
>
> #   MODULEDIR -- subdirectory of $PREFIX/share into which DATA and DOCS files
> #     should be installed (if not set, default is "extension" if EXTENSION
> #     is set, or "contrib" if not)
>
> And I mirrored that in meson.

Right, OK. This all seems rather confusing and a bit under-documented,
but after looking it over I think you've got it correct, so committed.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-17 21:45  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  4 siblings, 3 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-17 21:45 UTC (permalink / raw)
  To: PostgreSQL Hackers <[email protected]>

On Thu, Mar 12, 2026 at 1:15 PM Robert Haas <[email protected]> wrote:
> I'm still hoping to get some more feedback on the remaining patches,
> which are much smaller and conceptually simpler. While there is no
> time to redesign them at this point in the release cycle, there is
> still the opportunity to fix bugs, or decide that they're too
> half-baked to ship. So here is v20 with just those patches. Of course,
> post-commit review of the main patch is also very welcome.

I've now committed test_plan_advice, since it seems crazy to me to
have pg_plan_advice in the tree without it and reviewers evidently
agree at least with the concept test_plan_advice is something we
should have. Here are the remaining two patches, as v21.

Separately, I also committed a fix for the GEQO crash that Alexander
Lakhin found. The patch I proposed on list was missing a bms_copy() --
as proposed, it fixed the crash but was still wrong. I added that and
committed it.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v21-0002-Add-pg_stash_advice-contrib-module.patch (55.6K, 2-v21-0002-Add-pg_stash_advice-contrib-module.patch)
  download | inline diff:
From a02e1896cd2c908ccb32bced8702af18b390084a Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 27 Feb 2026 16:58:14 -0500
Subject: [PATCH v21 2/2] Add pg_stash_advice contrib module.

This module allows plan advice strings to be provided automatically
from an in-memory advice stash. Advice stashes are stored in dynamic
shared memory and must be recreated and repopulated after a server
restart. If pg_stash_advice.stash_name is set to the name of an advice
stash, and if query identifiers are enabled, the query identifier
for each query will be looked up in the advice stash and the
associated advice string, if any, will be used each time that query
is planned.
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_stash_advice/Makefile              |  26 +
 .../expected/pg_stash_advice.out              | 305 ++++++
 contrib/pg_stash_advice/meson.build           |  35 +
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |  43 +
 contrib/pg_stash_advice/pg_stash_advice.c     | 879 ++++++++++++++++++
 .../pg_stash_advice/pg_stash_advice.control   |   5 +
 .../pg_stash_advice/sql/pg_stash_advice.sql   | 130 +++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgstashadvice.sgml               | 218 +++++
 src/tools/pgindent/typedefs.list              |   6 +
 13 files changed, 1651 insertions(+)
 create mode 100644 contrib/pg_stash_advice/Makefile
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice.out
 create mode 100644 contrib/pg_stash_advice/meson.build
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice--1.0.sql
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.c
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.control
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice.sql
 create mode 100644 doc/src/sgml/pgstashadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 22071034e51..14e12d4fe2e 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -30,6 +30,7 @@ SUBDIRS = \
 		oid2name	\
 		pageinspect	\
 		passwordcheck	\
+		pg_stash_advice	\
 		pg_buffercache	\
 		pg_collect_advice \
 		pg_freespacemap \
diff --git a/contrib/meson.build b/contrib/meson.build
index ff422d9b7fc..4862ba97ed1 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -52,6 +52,7 @@ subdir('pg_overexplain')
 subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
+subdir('pg_stash_advice')
 subdir('pg_stat_statements')
 subdir('pgstattuple')
 subdir('pg_surgery')
diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
new file mode 100644
index 00000000000..cd9b7f30115
--- /dev/null
+++ b/contrib/pg_stash_advice/Makefile
@@ -0,0 +1,26 @@
+# contrib/pg_stash_advice/Makefile
+
+MODULE_big = pg_stash_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_stash_advice.o
+
+EXTENSION = pg_stash_advice
+DATA = pg_stash_advice--1.0.sql
+PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
+
+REGRESS = pg_stash_advice
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+ifdef USE_PGXS
+PG_CPPFLAGS = -I$(includedir_server)/extension
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice
+subdir = contrib/pg_stash_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
new file mode 100644
index 00000000000..0de6c10cdd1
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -0,0 +1,305 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
+(13 rows)
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+(13 rows)
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           2
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+ stash_name | advice_string 
+------------+---------------
+(0 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+ERROR:  advice stash "no_such_stash" does not exist
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           1
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   | advice_string 
+---------------+---------------
+ regress_stash | SEQ_SCAN(d1)
+(1 row)
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
+SELECT pg_drop_advice_stash('regress_empty_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
new file mode 100644
index 00000000000..b666bcd0f1b
--- /dev/null
+++ b/contrib/pg_stash_advice/meson.build
@@ -0,0 +1,35 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_stash_advice_sources = files(
+  'pg_stash_advice.c'
+)
+
+if host_system == 'windows'
+  pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_stash_advice',
+    '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',])
+endif
+
+pg_stash_advice = shared_module('pg_stash_advice',
+  pg_stash_advice_sources,
+  include_directories: [pg_plan_advice_inc, include_directories('.')],
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_stash_advice
+
+install_data(
+  'pg_stash_advice--1.0.sql',
+  'pg_stash_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_stash_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pg_stash_advice',
+    ],
+  },
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
new file mode 100644
index 00000000000..88dedd8ef1b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit
+
+CREATE FUNCTION pg_create_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_create_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_drop_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_drop_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint,
+									  advice_string text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_set_stashed_advice'
+LANGUAGE C;
+
+CREATE FUNCTION pg_get_advice_stashes(
+	OUT stash_name text,
+	OUT num_entries bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stashes'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_advice_stash_contents(
+	INOUT stash_name text,
+	OUT query_id bigint,
+	OUT advice_string text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
+LANGUAGE C;
+
+REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
new file mode 100644
index 00000000000..b2be3e5d639
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -0,0 +1,879 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.c
+ *	  Apply plan advice automatically, without SQL modifications.
+ *
+ * This module allows plan advice strings (as used and generated by
+ * pg_plan_advice) to be "stashed" in dynamic shared memory and, from
+ * there, automatically be applied to queries as they are planned.
+ * You can create any number of advice stashes, each of which is
+ * identified by a human-readable, ASCII name, and each of them is
+ * essentially a query ID -> advice_string mapping.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "common/string.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "lib/dshash.h"
+#include "nodes/queryjumble.h"
+#include "pg_plan_advice.h"
+#include "storage/dsm_registry.h"
+#include "storage/lwlock.h"
+#include "utils/builtins.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+#include "utils/tuplestore.h"
+
+PG_MODULE_MAGIC;
+
+PG_FUNCTION_INFO_V1(pg_create_advice_stash);
+PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
+PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
+PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
+PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+
+typedef struct pgsa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	int			stash_tranche;
+	int			entry_tranche;
+	uint64		next_stash_id;
+	dsa_handle	area;
+	dshash_table_handle stash_hash;
+	dshash_table_handle entry_hash;
+} pgsa_shared_state;
+
+typedef struct pgsa_stash
+{
+	char		name[NAMEDATALEN];
+	uint64		pgsa_stash_id;
+} pgsa_stash;
+
+typedef struct pgsa_entry_key
+{
+	uint64		pgsa_stash_id;
+	int64		queryId;
+} pgsa_entry_key;
+
+typedef struct pgsa_entry
+{
+	pgsa_entry_key key;
+	dsa_pointer advice_string;
+} pgsa_entry;
+
+typedef struct pgsa_stash_count
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	int64		num_entries;
+} pgsa_stash_count;
+
+#define SH_PREFIX pgsa_stash_count_table
+#define SH_ELEMENT_TYPE pgsa_stash_count
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct pgsa_stash_name
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	char	   *name;
+} pgsa_stash_name;
+
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/* Shared memory pointers */
+static pgsa_shared_state *pgsa_state;
+static dsa_area *pgsa_dsa_area;
+static dshash_table *pgsa_stash_dshash;
+static dshash_table *pgsa_entry_dshash;
+
+/* Shared memory hash table parameters */
+static dshash_parameters pgsa_stash_dshash_parameters = {
+	NAMEDATALEN,
+	sizeof(pgsa_stash),
+	dshash_strcmp,
+	dshash_strhash,
+	dshash_strcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+static dshash_parameters pgsa_entry_dshash_parameters = {
+	sizeof(pgsa_entry_key),
+	sizeof(pgsa_entry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+/* GUC variable */
+static char *pg_stash_advice_stash_name = "";
+
+/* Other global variables */
+static MemoryContext pg_stash_advice_mcxt;
+
+/* Function prototypes */
+static char *pgsa_advisor(PlannerGlobal *glob,
+						  Query *parse,
+						  const char *query_string,
+						  int cursorOptions,
+						  ExplainState *es);
+static void pgsa_attach(void);
+static void pgsa_check_stash_name(char *stash_name);
+static bool pgsa_check_stash_name_guc(char **newval, void **extra,
+									  GucSource source);
+static void pgsa_clear_advice_string(char *stash_name, int64 queryId);
+static void pgsa_create_stash(char *stash_name);
+static void pgsa_drop_stash(char *stash_name);
+static void pgsa_init_shared_state(void *ptr, void *arg);
+static uint64 pgsa_lookup_stash_id(char *stash_name);
+static void pgsa_set_advice_string(char *stash_name, int64 queryId,
+								   char *advice_string);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/* If compute_query_id = 'auto', we would like query IDs. */
+	EnableQueryId();
+
+	/* Define our GUCs. */
+	DefineCustomStringVariable("pg_stash_advice.stash_name",
+							   "Name of the advice stash to be used in this session.",
+							   NULL,
+							   &pg_stash_advice_stash_name,
+							   "",
+							   PGC_USERSET,
+							   0,
+							   pgsa_check_stash_name_guc,
+							   NULL,
+							   NULL);
+
+	MarkGUCPrefixReserved("pg_stash_advice");
+
+	/* Tell pg_plan_advice that we want to provide advice strings. */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+	(*add_advisor_fn) (pgsa_advisor);
+}
+
+/*
+ * SQL-callable function to create an advice stash
+ */
+Datum
+pg_create_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_create_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to drop an advice stash
+ */
+Datum
+pg_drop_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_drop_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to provide a list of advice stashes
+ */
+Datum
+pg_get_advice_stashes(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	pgsa_stash_count_table_hash *chash;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Tally up the number of entries per stash. */
+	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		pgsa_stash_count *c;
+		bool		found;
+
+		c = pgsa_stash_count_table_insert(chash,
+										  entry->key.pgsa_stash_id,
+										  &found);
+		if (!found)
+			c->num_entries = 1;
+		else
+			c->num_entries++;
+	}
+	dshash_seq_term(&iterator);
+
+	/* Emit results. */
+	dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[2];
+		bool		nulls[2];
+		pgsa_stash_count *c;
+
+		values[0] = CStringGetTextDatum(stash->name);
+		nulls[0] = false;
+
+		c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id);
+		values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries);
+		nulls[1] = false;
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to provide advice stash contents
+ */
+Datum
+pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	char	   *stash_name = NULL;
+	pgsa_stash_name_table_hash *nhash = NULL;
+	uint64		stash_id = 0;
+	pgsa_entry *entry;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* User can pass NULL for all stashes, or the name of a specific stash. */
+	if (!PG_ARGISNULL(0))
+	{
+		stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		pgsa_check_stash_name(stash_name);
+		stash_id = pgsa_lookup_stash_id(stash_name);
+
+		/* If the user specified a stash name, it should exist. */
+		if (stash_id == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("advice stash \"%s\" does not exist", stash_name));
+	}
+	else
+	{
+		pgsa_stash *stash;
+
+		/*
+		 * If we're dumping data about all stashes, we need an ID->name lookup
+		 * table.
+		 */
+		nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+		dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+		while ((stash = dshash_seq_next(&iterator)) != NULL)
+		{
+			pgsa_stash_name *n;
+			bool		found;
+
+			n = pgsa_stash_name_table_insert(nhash,
+											 stash->pgsa_stash_id,
+											 &found);
+			Assert(!found);
+			n->name = pstrdup(stash->name);
+		}
+		dshash_seq_term(&iterator);
+	}
+
+	/* Now iterate over all the entries. */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, false);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		char	   *this_stash_name;
+		char	   *advice_string;
+
+		/* Skip incomplete entries where the advice string was never set. */
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		if (stash_id != 0)
+		{
+			/*
+			 * We're only dumping data for one particular stash, so skip
+			 * entries for any other stash and use the stash name specified by
+			 * the user.
+			 */
+			if (stash_id != entry->key.pgsa_stash_id)
+				continue;
+			this_stash_name = stash_name;
+		}
+		else
+		{
+			pgsa_stash_name *n;
+
+			/*
+			 * We're dumping data for all stashes, so look up the correct name
+			 * to use in the hash table. If nothing is found, which is
+			 * possible due to race conditions, make up a string to use.
+			 */
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n != NULL)
+				this_stash_name = n->name;
+			else
+				this_stash_name = psprintf("<stash %" PRIu64 ">",
+										   entry->key.pgsa_stash_id);
+		}
+
+		/* Work out tuple values. */
+		values[0] = CStringGetTextDatum(this_stash_name);
+		nulls[0] = false;
+		values[1] = Int64GetDatum(entry->key.queryId);
+		nulls[1] = false;
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+		values[2] = CStringGetTextDatum(advice_string);
+		nulls[2] = false;
+
+		/* Emit the tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to update an advice stash entry for a particular
+ * query ID
+ *
+ * If the second argument is NULL, we delete any existing advice stash
+ * entry; otherwise, we either create an entry or update it with the new
+ * advice string.
+ */
+Datum
+pg_set_stashed_advice(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name;
+	int64		queryId;
+
+	if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+		PG_RETURN_NULL();
+
+	/* Get and check advice stash name. */
+	stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	pgsa_check_stash_name(stash_name);
+
+	/*
+	 * Get and check query ID.
+	 *
+	 * queryID 0 means no query ID was computed, so reject that.
+	 */
+	queryId = PG_GETARG_INT64(1);
+	if (queryId == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("cannot set advice string for query ID 0"));
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Now call the appropriate function to do the real work. */
+	if (PG_ARGISNULL(2))
+		pgsa_clear_advice_string(stash_name, queryId);
+	else
+	{
+		char	   *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+		pgsa_set_advice_string(stash_name, queryId, advice_string);
+	}
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * Get the advice string that has been configured for this query, if any,
+ * and return it. Otherwise, return NULL.
+ */
+static char *
+pgsa_advisor(PlannerGlobal *glob, Query *parse,
+			 const char *query_string, int cursorOptions,
+			 ExplainState *es)
+{
+	pgsa_entry_key key;
+	pgsa_entry *entry;
+	char	   *advice_string;
+	uint64		stash_id;
+
+	/*
+	 * Exit quickly if the stash name is empty or there's no query ID.
+	 */
+	if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0)
+		return NULL;
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/*
+	 * Translate pg_stash_advice.stash_name to an integer ID.
+	 *
+	 * pgsa_check_stash_name_guc() has already validated the advice stash
+	 * name, so we don't need to call pgsa_check_stash_name() here.
+	 */
+	stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name);
+	if (stash_id == 0)
+		return NULL;
+
+	/*
+	 * Look up the advice string for the given stash ID + query ID.
+	 *
+	 * If we find an advice string, we copy it into the current memory
+	 * context, presumably short-lived, so that we can release the lock on the
+	 * dshash entry. pg_plan_advice only needs the value to remain allocated
+	 * long enough for it to be parsed, so this should be good enough.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = parse->queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		return NULL;
+	if (entry->advice_string == InvalidDsaPointer)
+		advice_string = NULL;
+	else
+		advice_string = pstrdup(dsa_get_address(pgsa_dsa_area,
+												entry->advice_string));
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we found an advice string, emit a debug message. */
+	if (advice_string != NULL)
+		elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s",
+			 pg_stash_advice_stash_name, key.queryId, advice_string);
+
+	return advice_string;
+}
+
+/*
+ * Attach to various structures in dynamic shared memory.
+ *
+ * This function is designed to be resilient against errors. That is, if it
+ * fails partway through, it should be possible to call it again, repeat no
+ * work already completed, and potentially succeed or at least get further if
+ * whatever caused the previous failure has been corrected.
+ */
+static void
+pgsa_attach(void)
+{
+	bool		found;
+	MemoryContext oldcontext;
+
+	/*
+	 * Create a memory context to make sure that any control structures
+	 * allocated in local memory are sufficiently persistent.
+	 */
+	if (pg_stash_advice_mcxt == NULL)
+		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
+													 "pg_stash_advice",
+													 ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
+
+	/* Attach to the fixed-size state object if not already done. */
+	if (pgsa_state == NULL)
+		pgsa_state = GetNamedDSMSegment("pg_stash_advice",
+										sizeof(pgsa_shared_state),
+										pgsa_init_shared_state,
+										&found, NULL);
+
+	/* Attach to the DSA area if not already done. */
+	if (pgsa_dsa_area == NULL)
+	{
+		dsa_handle	area_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		area_handle = pgsa_state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
+			dsa_pin(pgsa_dsa_area);
+			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_dsa_area = dsa_attach(area_handle);
+		}
+		dsa_pin_mapping(pgsa_dsa_area);
+	}
+
+	/* Attach to the stash_name->stash_id hash table if not already done. */
+	if (pgsa_stash_dshash == NULL)
+	{
+		dshash_table_handle stash_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
+		stash_handle = pgsa_state->stash_hash;
+		if (stash_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  NULL);
+			pgsa_state->stash_hash =
+				dshash_get_hash_table_handle(pgsa_stash_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  stash_handle, NULL);
+		}
+	}
+
+	/* Attach to the entry hash table if not already done. */
+	if (pgsa_entry_dshash == NULL)
+	{
+		dshash_table_handle entry_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
+		entry_handle = pgsa_state->entry_hash;
+		if (entry_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  NULL);
+			pgsa_state->entry_hash =
+				dshash_get_hash_table_handle(pgsa_entry_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  entry_handle, NULL);
+		}
+	}
+
+	/* Restore previous memory context. */
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Check whether an advice stash name is legal, and signal an error if not.
+ *
+ * Keep this in sync with pgsa_check_stash_name_guc, below.
+ */
+static void
+pgsa_check_stash_name(char *stash_name)
+{
+	/* Reject empty advice stash name. */
+	if (stash_name[0] == '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name may not be zero length"));
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash names may not be longer than %d bytes",
+					   NAMEDATALEN - 1));
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must not contain non-ASCII characters"));
+}
+
+/*
+ * As above, but for the GUC check_hook. We allow the empty string here,
+ * though, as equivalent to disabling the feature.
+ */
+static bool
+pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source)
+{
+	char	   *stash_name = *newval;
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash names may not be longer than %d bytes",
+							NAMEDATALEN - 1);
+		return false;
+	}
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must not contain non-ASCII characters");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Create an advice stash.
+ */
+static void
+pgsa_create_stash(char *stash_name)
+{
+	pgsa_stash *stash;
+	bool		found;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Create a stash with this name, unless one already exists. */
+	stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found);
+	if (found)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" already exists", stash_name));
+	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+}
+
+/*
+ * Remove any stored advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_clear_advice_string(char *stash_name, int64 queryId)
+{
+	pgsa_entry *entry;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer old_dp;
+
+	/* Translate the stash name to an integer ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/*
+	 * Look for an existing entry, and free it. But, be sure to save the
+	 * pointer to the associated advice string, if any.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		old_dp = InvalidDsaPointer;
+	else
+	{
+		old_dp = entry->advice_string;
+		dshash_delete_entry(pgsa_entry_dshash, entry);
+	}
+
+	/* Now we free the advice string as well, if there was one. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
+
+/*
+ * Drop an advice stash.
+ */
+static void
+pgsa_drop_stash(char *stash_name)
+{
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	dshash_seq_status iterator;
+	uint64		stash_id;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove the entry for this advice stash. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, true);
+	if (stash == NULL)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+	stash_id = stash->pgsa_stash_id;
+	dshash_delete_entry(pgsa_stash_dshash, stash);
+
+	/*
+	 * It should now be impossible for any new entries to be added for the
+	 * advice stash we just deleted. Go through and clean out all the existing
+	 * ones.
+	 */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		if (stash_id == entry->key.pgsa_stash_id)
+		{
+			if (entry->advice_string != InvalidDsaPointer)
+				dsa_free(pgsa_dsa_area, entry->advice_string);
+			dshash_delete_current(&iterator);
+		}
+	}
+	dshash_seq_term(&iterator);
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgsa_init_shared_state(void *ptr, void *arg)
+{
+	pgsa_shared_state *state = (pgsa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_stash_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
+	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
+	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
+	state->next_stash_id = UINT64CONST(1);
+	state->area = DSA_HANDLE_INVALID;
+	state->stash_hash = DSHASH_HANDLE_INVALID;
+	state->entry_hash = DSHASH_HANDLE_INVALID;
+}
+
+/*
+ * Look up the integer ID that corresponds to the given stash name.
+ *
+ * Returns 0 if no such stash exists.
+ */
+static uint64
+pgsa_lookup_stash_id(char *stash_name)
+{
+	pgsa_stash *stash;
+	uint64		stash_id;
+
+	/* Search the shared hash table. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, false);
+	if (stash == NULL)
+		return 0;
+	stash_id = stash->pgsa_stash_id;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	return stash_id;
+}
+
+/*
+ * Store a new or updated advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
+{
+	pgsa_entry *entry;
+	bool		found;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer new_dp;
+	dsa_pointer old_dp;
+
+	/* Translate the stash name to an integer ID. */
+restart:
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/* Make sure that an entry exists. */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find_or_insert(pgsa_entry_dshash, &key, &found);
+	if (!found)
+		entry->advice_string = InvalidDsaPointer;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/*
+	 * Copy the advice string into dynamic shared memory.
+	 *
+	 * If we fail after this point, we'll have a server-lifespan memory leak.
+	 * We assume that, having created the entry above, we'll be able to find
+	 * it again without an error.
+	 */
+	new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1);
+	strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string);
+
+	/*
+	 * Refind the entry and swap the new pointer into place.
+	 *
+	 * If the entry has been deleted since we found or created it above, free
+	 * memory and retry from the top.
+	 */
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+	{
+		dsa_free(pgsa_dsa_area, new_dp);
+		goto restart;
+	}
+	old_dp = entry->advice_string;
+	entry->advice_string = new_dp;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we replaced an old advice string, free it. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control
new file mode 100644
index 00000000000..4a0fff5c866
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.control
@@ -0,0 +1,5 @@
+# pg_stash_advice extension
+comment = 'store and automatically apply plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_stash_advice'
+relocatable = true
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
new file mode 100644
index 00000000000..aed2d2a5a9a
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
@@ -0,0 +1,130 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+SET pg_stash_advice.stash_name = 'regress_stash';
+
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('regress_empty_stash');
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index 2ab6fafbab1..8f09d728698 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -160,6 +160,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 407ff3abffe..8c14bab84e9 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -144,6 +144,7 @@
 <!ENTITY oid2name        SYSTEM "oid2name.sgml">
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
+<!ENTITY pgstashadvice   SYSTEM "pgstashadvice.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
 <!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
new file mode 100644
index 00000000000..089fc66446f
--- /dev/null
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -0,0 +1,218 @@
+<!-- doc/src/sgml/pgstashadvice.sgml -->
+
+<sect1 id="pgstashadvice" xreflabel="pg_stash_advice">
+ <title>pg_stash_advice &mdash; store and automatically apply plan advice</title>
+
+ <indexterm zone="pgstashadvice">
+  <primary>pg_stash_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_stash_advice</filename> extension allows you to stash
+  <link linkend="pgplanadvice">plan advice</link> strings in dynamic
+  shared memory where they can be automatically applied. An
+  <literal>advice stash</literal> is a mapping from
+  <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
+  strings. Whenever a session is asked to plan a query whose query ID appears
+  in the relevant advice stash, the plan advice string is automatically applied
+  to guide planning. Note that advice stashes exist purely in memory. This
+  means both that it is important to be mindful of memory consumption when
+  deciding how much plan advice to stash, and also that advice stashes must
+  be recreated and repopulated whenever the server is restarted.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
+  one database, so that you have access to the SQL functions to manage
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
+  module to be loaded in all sessions where you want this module to
+  automatically apply advice. It will usually be best to do this by adding
+  <literal>pg_stash_advice</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> and restarting the server.
+ </para>
+
+ <para>
+  Once you have met the above criteria, you can create advice stashes
+  using the <literal>pg_create_advice_stash</literal> function described
+  below and set the plan advice for a given query ID in a given stash using
+  the <literal>pg_set_stashed_advice</literal> function. Then, you need
+  only configure <literal>pg_stash_advice.stash_name</literal> to point
+  to the chosen advice stash name. For some use cases, rather than setting
+  this on a system-wide basis, you may find it helpful to use
+  <literal>ALTER DATABASE ... SET</literal> or
+  <literal>ALTER ROLE ... SET</literal> to configure values that will apply
+  only to a database or only to a certain role. Likewise, it may sometimes
+  be better to set the stash name in a particular session using
+  <literal>SET</literal>.
+ </para>
+
+ <para>
+  Because <literal>pg_stash_advice</literal> works on the basis of query
+  identifiers, you will need to determine the query identifier for each query
+  whose plan you wish to control. You will also need to determine the advice
+  string that you wish to store for each query. One way to do this is to use
+  <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
+  show the query ID, and the <literal>PLAN_ADVICE</literal> option will
+  show plan advice. <xref linkend="pgcollectadvice" /> can be used to
+  obtain this information for an entire workload, although care must be
+  taken since it can use up a lot of memory very quickly. Query identifiers can
+  also be obtained through tools such as <xref linkend="pgstatstatements" />
+  or <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
+  will not provide plan advice strings. Note that
+  <xref linkend="guc-compute-query-id" /> must be enabled for query
+  identifiers to be computed; if set to <literal>auto</literal>, loading
+  <literal>pg_stash_advice</literal> will enable it automatically.
+ </para>
+
+ <para>
+  Generally, the fact that the planner is able to change query plans as
+  the underlying distribution of data changes is a feature, not a bug.
+  Moreover, applying plan advice can have a noticeable performance cost even
+  when it does not result in a change to the query plan. Therefore, it is
+  a good idea to use this feature only when and to the extent needed.
+  Plan advice strings can be trimmed down to mention only those aspects
+  of the plan that need to be controlled, and used only for queries where
+  there is believed to be a significant risk of planner error.
+ </para>
+
+ <para>
+  Note that <literal>pg_stash_advice</literal> currently lacks a sophisticated
+  security model. Only the superuser, or a user to whom the superuser has
+  granted <literal>EXECUTE</literal> permission on the relevant functions,
+  may create advice stashes or alter their contents, but any user may set
+  <literal>pg_stash_advice.stash_name</literal> for their session, and this
+  may reveal the contents of any advice stash with that name. Users should
+  assume that information embedded in stashed advice strings may become visible
+  to nonprivileged users.
+ </para>
+
+ <sect2 id="pgstashadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_create_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_create_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Creates a new, empty advice stash with the given name.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_drop_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_drop_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Drops the named advice stash and all of its entries.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_set_stashed_advice(stash_name text, query_id bigint,
+       advice_string text) returns void</function>
+     <indexterm>
+      <primary>pg_set_stashed_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Stores an advice string in the named advice stash, associated with
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
+      <parameter>advice_string</parameter> is <literal>NULL</literal>,
+      any existing entry for that query identifier is removed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stashes() returns setof (stash_name text,
+       num_entries bigint)</function>
+     <indexterm>
+      <primary>pg_get_advice_stashes</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each advice stash, showing the stash name and
+      the number of entries it contains.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stash_contents(stash_name text) returns setof
+       (stash_name text, query_id bigint, advice_string text)</function>
+     <indexterm>
+      <primary>pg_get_advice_stash_contents</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each entry in the named advice stash. If
+      <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
+      entries from all stashes.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.stash_name</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the name of the advice stash to consult during query
+      planning. The default value is the empty string, which disables
+      this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 7a233a88a09..a4be5f38588 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4042,6 +4042,12 @@ pgpa_trove_lookup_type
 pgpa_trove_result
 pgpa_trove_slice
 pgpa_unrolled_join
+pgsa_entry
+pgsa_entry_key
+pgsa_shared_state
+pgsa_stash
+pgsa_stash_count
+pgsa_stash_name
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



  [application/octet-stream] v21-0001-Add-pg_collect_advice-contrib-module.patch (56.2K, 3-v21-0001-Add-pg_collect_advice-contrib-module.patch)
  download | inline diff:
From 346738cb3a9569a0f48798c741e849c367e2950a Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Thu, 26 Feb 2026 16:51:16 -0500
Subject: [PATCH v21 1/2] Add pg_collect_advice contrib module.

This module allows automated, large-scale collection of queries and
the associated plan advice strings using either backend-local memory
or dynamic shared memory. In either case, memory usage can be limited
by restriction the maximum number of queries and advice strings
stored. Care should be taken with these values, and with the use of
this module in general, because it's easy to chew up an unreasonably
large amount of memory. Unlike pg_stat_statements, this module does
not provide for query normalization or even deduplication; it simply
makes a record for every query planned.

It can be useful to enable query ID computaton before using the
module, but it's not required. If not done, all queries will simply
show a query ID of zero.

Reviewed-by: Alexandra Wang <[email protected]> (earlier version)
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_collect_advice/Makefile            |  30 +
 contrib/pg_collect_advice/collector.c         | 642 ++++++++++++++++++
 .../expected/local_collector.out              |  69 ++
 contrib/pg_collect_advice/interface.c         | 303 +++++++++
 contrib/pg_collect_advice/meson.build         |  41 ++
 .../pg_collect_advice--1.0.sql                |  43 ++
 .../pg_collect_advice.control                 |   5 +
 contrib/pg_collect_advice/pg_collect_advice.h |  39 ++
 .../pg_collect_advice/sql/local_collector.sql |  46 ++
 contrib/pg_collect_advice/t/001_regress.pl    | 151 ++++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgcollectadvice.sgml             | 244 +++++++
 src/tools/pgindent/typedefs.list              |   6 +
 16 files changed, 1623 insertions(+)
 create mode 100644 contrib/pg_collect_advice/Makefile
 create mode 100644 contrib/pg_collect_advice/collector.c
 create mode 100644 contrib/pg_collect_advice/expected/local_collector.out
 create mode 100644 contrib/pg_collect_advice/interface.c
 create mode 100644 contrib/pg_collect_advice/meson.build
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice--1.0.sql
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.control
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.h
 create mode 100644 contrib/pg_collect_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_collect_advice/t/001_regress.pl
 create mode 100644 doc/src/sgml/pgcollectadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index dd04c20acd2..22071034e51 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -31,6 +31,7 @@ SUBDIRS = \
 		pageinspect	\
 		passwordcheck	\
 		pg_buffercache	\
+		pg_collect_advice \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
diff --git a/contrib/meson.build b/contrib/meson.build
index 5a752eac347..ff422d9b7fc 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -45,6 +45,7 @@ subdir('pageinspect')
 subdir('passwordcheck')
 subdir('pg_buffercache')
 subdir('pgcrypto')
+subdir('pg_collect_advice')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
diff --git a/contrib/pg_collect_advice/Makefile b/contrib/pg_collect_advice/Makefile
new file mode 100644
index 00000000000..dfd8e9e665b
--- /dev/null
+++ b/contrib/pg_collect_advice/Makefile
@@ -0,0 +1,30 @@
+# contrib/pg_collect_advice/Makefile
+
+MODULE_big = pg_collect_advice
+OBJS = \
+	$(WIN32RES) \
+	collector.o \
+	interface.o
+
+EXTENSION = pg_collect_advice
+DATA = pg_collect_advice--1.0.sql
+PGFILEDESC = "pg_collect_advice - collect queries and their plan advice strings"
+
+REGRESS = local_collector
+EXTRA_INSTALL = contrib/pg_plan_advice
+TAP_TESTS = 1
+
+# required for 001_regress.pl
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_collect_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_collect_advice/collector.c b/contrib/pg_collect_advice/collector.c
new file mode 100644
index 00000000000..e0ece42bb6d
--- /dev/null
+++ b/contrib/pg_collect_advice/collector.c
@@ -0,0 +1,642 @@
+/*-------------------------------------------------------------------------
+ *
+ * collector.c
+ *	  workhorse for saving plan advice in backend-local or shared memory
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+#include "utils/tuplestore.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgca_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgca_collected_advice;
+
+/*
+ * A bunch of pointers to pgca_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgca_local_advice_chunk
+{
+	pgca_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgca_local_advice_chunk;
+
+/*
+ * Information about all of the pgca_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgca_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgca_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgca_local_advice_chunk **chunks;
+} pgca_local_advice;
+
+/*
+ * Just like pgca_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgca_shared_advice_chunk;
+
+/*
+ * Just like pgca_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgca_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgca_local_advice *local_collector = NULL;
+static pgca_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgca_collected_advice *make_collected_advice(Oid userid,
+													Oid dbid,
+													uint64 queryId,
+													TimestampTz timestamp,
+													const char *query_string,
+													const char *advice_string,
+													dsa_area *area,
+													dsa_pointer *result);
+static void store_local_advice(pgca_collected_advice *ca);
+static void trim_local_advice(int limit);
+static void store_shared_advice(dsa_pointer ca_pointer);
+static void trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgca_collected_advice */
+static inline const char *
+query_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgca_collected_advice */
+static inline const char *
+advice_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pg_collect_advice_save(uint64 queryId, const char *query_string,
+					   const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_collect_advice_local_collector &&
+		pg_collect_advice_local_collection_limit > 0)
+	{
+		pgca_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+		ca = make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string,
+								   NULL, NULL);
+		store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_collect_advice_shared_collector &&
+		pg_collect_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_collect_advice_dsa_area();
+		dsa_pointer ca_pointer = InvalidDsaPointer; /* placate compiler */
+
+		make_collected_advice(userid, dbid, queryId, now,
+							  query_string, advice_string, area,
+							  &ca_pointer);
+		store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgca_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgca_collected_advice *
+make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+					  TimestampTz timestamp,
+					  const char *query_string,
+					  const char *advice_string,
+					  dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgca_collected_advice *ca;
+
+	total_length = offsetof(pgca_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = userid;
+	ca->dbid = dbid;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pgca_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+store_local_advice(pgca_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgca_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgca_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgca_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgca_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_local_advice(pg_collect_advice_local_collection_limit);
+}
+
+/*
+ * Add a pgca_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_collect_advice DSA area
+ * and should point to an object of type pgca_collected_advice.
+ */
+static void
+store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	pgca_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgca_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgca_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgca_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_shared_advice(area, pg_collect_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+trim_local_advice(int limit)
+{
+	pgca_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgca_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgca_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+trim_shared_advice(dsa_area *area, int limit)
+{
+	pgca_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(dsa_pointer) * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in shared memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgca_shared_advice *sa = shared_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_collect_advice/expected/local_collector.out b/contrib/pg_collect_advice/expected/local_collector.out
new file mode 100644
index 00000000000..f57b96ee835
--- /dev/null
+++ b/contrib/pg_collect_advice/expected/local_collector.out
@@ -0,0 +1,69 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_collect_advice/interface.c b/contrib/pg_collect_advice/interface.c
new file mode 100644
index 00000000000..feb11974152
--- /dev/null
+++ b/contrib/pg_collect_advice/interface.c
@@ -0,0 +1,303 @@
+/*-------------------------------------------------------------------------
+ *
+ * interface.c
+ *	  interface routines for the plan advice collector
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/interface.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+/* Shared memory pointers */
+static pgca_shared_state *pgca_state = NULL;
+static dsa_area *pgca_dsa_area = NULL;
+
+/* GUC variables */
+bool		pg_collect_advice_local_collector = false;
+int			pg_collect_advice_local_collection_limit = 0;
+bool		pg_collect_advice_shared_collector = false;
+int			pg_collect_advice_shared_collection_limit = 0;
+
+/* Shadow variables for GUC assign hooks */
+static bool pg_collect_advice_local_collector_as_assigned = false;
+static bool pg_collect_advice_shared_collector_as_assigned = false;
+
+/* Other file-level globals */
+static void (*request_advice_generation_fn) (bool activate) = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+static MemoryContext pgca_memory_context = NULL;
+
+/* Function prototypes */
+static void pgca_init_shared_state(void *ptr, void *arg);
+static void pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string,
+								  PlannedStmt *pstmt);
+static void pg_collect_advice_local_collector_assign_hook(bool newval,
+														  void *extra);
+static void pg_collect_advice_shared_collector_assign_hook(bool newval,
+														   void *extra);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	/*
+	 * Get a pointer so we can call pg_plan_advice_request_advice_generation.
+	 *
+	 * We need to do this before defining custom GUCs; otherwise, our assign
+	 * hook will try to use this function pointer before it's initialized.
+	 *
+	 * We also need to do this before installing our own hooks, so that if
+	 * pg_plan_advice is not yet loaded, it will install its hooks before we
+	 * install ours. (See comments in pgca_planner_shutdown.)
+	 */
+	request_advice_generation_fn =
+		load_external_function("pg_plan_advice",
+							   "pg_plan_advice_request_advice_generation",
+							   true, NULL);
+
+	/* Define our GUCs. */
+	DefineCustomBoolVariable("pg_collect_advice.local_collector",
+							 "Enable the local advice collector.",
+							 NULL,
+							 &pg_collect_advice_local_collector,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_local_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_collect_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomBoolVariable("pg_collect_advice.shared_collector",
+							 "Enable the shared advice collector.",
+							 NULL,
+							 &pg_collect_advice_shared_collector,
+							 false,
+							 PGC_SUSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_shared_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_collect_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_collect_advice");
+
+	/* Install hooks */
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgca_planner_shutdown;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgca_init_shared_state(void *ptr, void *arg)
+{
+	pgca_shared_state *state = (pgca_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_collect_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_collect_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_collect_advice_get_mcxt(void)
+{
+	if (pgca_memory_context == NULL)
+		pgca_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_collect_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgca_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ */
+pgca_shared_state *
+pg_collect_advice_attach(void)
+{
+	if (pgca_state == NULL)
+	{
+		bool		found;
+
+		pgca_state =
+			GetNamedDSMSegment("pg_collect_advice", sizeof(pgca_shared_state),
+							   pgca_init_shared_state, &found, NULL);
+	}
+
+	return pgca_state;
+}
+
+/*
+ * Return a pointer to pg_collect_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_collect_advice_dsa_area(void)
+{
+	if (pgca_dsa_area == NULL)
+	{
+		pgca_shared_state *state = pg_collect_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgca_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgca_dsa_area);
+			state->area = dsa_get_handle(pgca_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgca_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgca_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgca_dsa_area;
+}
+
+/*
+ * After planning is complete, retrieve the advice string, if present, and
+ * pass it through to the collector.
+ */
+static void
+pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	DefElem    *pgpa_item;
+	DefElem    *advice_string_item;
+	char	   *advice_string;
+
+	/*
+	 * Pass call to previous hook.
+	 *
+	 * We want to be called after pg_plan_advice's shutdown hook has already
+	 * executed. Our _PG_init() makes sure that pg_plan_advice's hooks are
+	 * always loaded before ours, and here we pass the hook call down first,
+	 * before doing our own work. The combination of those two things should
+	 * be good enough to ensure that the advice string is already present when
+	 * we go looking for it.
+	 */
+	if (prev_planner_shutdown)
+		(*prev_planner_shutdown) (glob, parse, query_string, pstmt);
+
+	/* Fish out the advice string. If not found, do nothing. */
+	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
+										"pg_plan_advice");
+	if (pgpa_item == NULL)
+		return;
+	advice_string_item = find_defelem_by_defname((List *) pgpa_item->arg,
+												 "advice_string");
+	if (advice_string_item == NULL)
+		return;
+	advice_string = strVal(advice_string_item->arg);
+
+	/*
+	 * Pass it through to the actual collector. But, if it's the empty string,
+	 * we assume that collecting it is uninteresting.
+	 */
+	if (advice_string[0] != '\0')
+		pg_collect_advice_save(pstmt->queryId, query_string, advice_string);
+}
+
+/*
+ * pgca_planner_shutdown won't find any advice to collect unless we've
+ * requested that it be generated. So, whenever the effective value of
+ * pg_collect_advice.local_collector changes, either make or
+ * revoke a request for advice generation.
+ */
+static void
+pg_collect_advice_local_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_local_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_local_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_local_collector_as_assigned = newval;
+}
+
+/*
+ * Same as above, but for pg_collect_advice.shared_collector
+ */
+static void
+pg_collect_advice_shared_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_shared_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_shared_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_shared_collector_as_assigned = newval;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_collect_advice/meson.build b/contrib/pg_collect_advice/meson.build
new file mode 100644
index 00000000000..ca7d5ecff1a
--- /dev/null
+++ b/contrib/pg_collect_advice/meson.build
@@ -0,0 +1,41 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_collect_advice_sources = files(
+  'collector.c',
+  'interface.c',
+)
+
+if host_system == 'windows'
+  pg_collect_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_collect_advice',
+    '--FILEDESC', 'pg_collect_advice - collect queries and their plan advice strings',])
+endif
+
+pg_collect_advice = shared_module('pg_collect_advice',
+  pg_collect_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_collect_advice
+
+install_data(
+  'pg_collect_advice--1.0.sql',
+  'pg_collect_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_collect_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'local_collector',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_regress.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_collect_advice/pg_collect_advice--1.0.sql b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
new file mode 100644
index 00000000000..0be86c54fc1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_collect_advice/pg_collect_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_collect_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_clear_collected_shared_advice() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_collect_advice/pg_collect_advice.control b/contrib/pg_collect_advice/pg_collect_advice.control
new file mode 100644
index 00000000000..601e5e24ea1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.control
@@ -0,0 +1,5 @@
+# pg_collect_advice extension
+comment = 'collect queries and the associated plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_collect_advice'
+relocatable = true
diff --git a/contrib/pg_collect_advice/pg_collect_advice.h b/contrib/pg_collect_advice/pg_collect_advice.h
new file mode 100644
index 00000000000..480c2c633c4
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.h
@@ -0,0 +1,39 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_collect_advice.h
+ *	  definitions and declarations for pg_collect_advice module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/pg_collect_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_COLLECT_ADVICE_H
+#define PG_COLLECT_ADVICE_H
+
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgca_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgca_shared_state;
+
+/* GUC variables */
+extern bool pg_collect_advice_local_collector;
+extern int	pg_collect_advice_local_collection_limit;
+extern bool pg_collect_advice_shared_collector;
+extern int	pg_collect_advice_shared_collection_limit;
+
+/* Function prototypes */
+extern MemoryContext pg_collect_advice_get_mcxt(void);
+extern pgca_shared_state *pg_collect_advice_attach(void);
+extern dsa_area *pg_collect_advice_dsa_area(void);
+extern void pg_collect_advice_save(uint64 queryId, const char *query_string,
+								   const char *advice_string);
+
+#endif
diff --git a/contrib/pg_collect_advice/sql/local_collector.sql b/contrib/pg_collect_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..41b187c5375
--- /dev/null
+++ b/contrib/pg_collect_advice/sql/local_collector.sql
@@ -0,0 +1,46 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_collect_advice/t/001_regress.pl b/contrib/pg_collect_advice/t/001_regress.pl
new file mode 100644
index 00000000000..ed934d0c859
--- /dev/null
+++ b/contrib/pg_collect_advice/t/001_regress.pl
@@ -0,0 +1,151 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Run the core regression tests under pg_collect_advice and pg_plan_advice
+# to check for problems.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Set up our desired configuration.
+#
+# We run with pg_collect_advice.shared_collection_limit set to ensure that the
+# plan tree walker code runs against every query in the regression tests. If
+# we're unable to properly analyze any of those plan trees, this test should
+# hopefully fail.
+#
+# We set pg_collect_advice.advice to an advice string that will cause the advice
+# trove to be populated with a few entries of various sorts, but which we do
+# not expect to match anything in the regression test queries. This way, the
+# planner hooks will be called, improving code coverage, but no plans should
+# actually change.
+#
+# pg_plan_advice.always_explain_supplied_advice=false is needed to avoid
+# breaking regression test queries that use EXPLAIN. In the real world, it
+# seems like users will want EXPLAIN output to show supplied advice so that
+# it's clear whether normal planner behavior has been altered, but here that's
+# undesirable.
+$node->append_conf('postgresql.conf', <<EOM);
+shared_preload_libraries=pg_collect_advice
+pg_collect_advice.shared_collection_limit=1000000
+pg_collect_advice.shared_collector=true
+pg_plan_advice.advice='SEQ_SCAN(entirely_fictitious) HASH_JOIN(total_fabrication) GATHER(completely_imaginary)'
+pg_plan_advice.always_explain_supplied_advice=false
+EOM
+$node->start;
+
+my $srcdir = abs_path("../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--host=" . $node->host . " "
+	  . "--port=" . $node->port . " "
+	  . "--schedule=$srcdir/src/test/regress/parallel_schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+# Create the extension so we can access the collector
+$node->safe_psql('postgres', 'CREATE EXTENSION pg_collect_advice');
+
+# Verify that a large amount of advice was collected
+my $all_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice();
+EOM
+cmp_ok($all_query_count, '>', 20000, "copious advice collected");
+
+# Verify that lots of different advice strings were collected
+my $distinct_query_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM
+	(SELECT DISTINCT advice FROM pg_get_collected_shared_advice());
+EOM
+cmp_ok($distinct_query_count, '>', 3000, "diverse advice collected");
+
+# We want to test for the presence of our known tags in the collected advice.
+# Put all tags into the hash that follows; map any tags that aren't tested
+# by the core regression tests to 0, and others to 1.
+my %tag_map = (
+	BITMAP_HEAP_SCAN => 1,
+	FOREIGN_JOIN => 0,
+	GATHER => 1,
+	GATHER_MERGE => 1,
+	HASH_JOIN => 1,
+	INDEX_ONLY_SCAN => 1,
+	INDEX_SCAN => 1,
+	JOIN_ORDER => 1,
+	MERGE_JOIN_MATERIALIZE => 1,
+	MERGE_JOIN_PLAIN => 1,
+	NESTED_LOOP_MATERIALIZE => 1,
+	NESTED_LOOP_MEMOIZE => 1,
+	NESTED_LOOP_PLAIN => 1,
+	NO_GATHER => 1,
+	PARTITIONWISE => 1,
+	SEMIJOIN_NON_UNIQUE => 1,
+	SEMIJOIN_UNIQUE => 1,
+	SEQ_SCAN => 1,
+	TID_SCAN => 1,
+);
+for my $tag (sort keys %tag_map)
+{
+	my $checkit = $tag_map{$tag};
+
+	# Search for the given tag. This is not entirely robust: it could get thrown
+	# off by a table alias such as "FOREIGN_JOIN(", but that probably won't
+	# happen in the core regression tests.
+	my $tag_count = $node->safe_psql('postgres', <<EOM);
+SELECT COUNT(*) FROM pg_get_collected_shared_advice()
+	WHERE advice LIKE '%$tag(%'
+EOM
+
+	# Check that the tag got a non-trivial amount of use, unless told otherwise.
+	cmp_ok($tag_count, '>', 10, "multiple uses of $tag") if $checkit;
+
+	# Regardless, note the exact count in the log, for human consumption.
+	note("found $tag_count advice strings containing $tag");
+}
+
+# Trigger a partial cleanup of the shared advice collector, and then a full
+# cleanup.
+$node->safe_psql('postgres', <<EOM);
+SET pg_collect_advice.shared_collection_limit=500;
+SELECT * FROM pg_clear_collected_shared_advice();
+EOM
+
+done_testing();
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index bdd4865f53f..2ab6fafbab1 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -152,6 +152,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pageinspect;
  &passwordcheck;
  &pgbuffercache;
+ &pgcollectadvice;
  &pgcrypto;
  &pgfreespacemap;
  &pglogicalinspect;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index d90b4338d2a..407ff3abffe 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -145,6 +145,7 @@
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
+<!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
 <!ENTITY pglogicalinspect  SYSTEM "pglogicalinspect.sgml">
diff --git a/doc/src/sgml/pgcollectadvice.sgml b/doc/src/sgml/pgcollectadvice.sgml
new file mode 100644
index 00000000000..220aabe78c6
--- /dev/null
+++ b/doc/src/sgml/pgcollectadvice.sgml
@@ -0,0 +1,244 @@
+<!-- doc/src/sgml/pgcollectadvice.sgml -->
+
+<sect1 id="pgcollectadvice" xreflabel="pg_collect_advice">
+ <title>pg_collect_advice &mdash; collect queries and their plan advice strings</title>
+
+ <indexterm zone="pgcollectadvice">
+  <primary>pg_collect_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_collect_advice</filename> extension allows you to
+  automatically generate plan advice each time a query is planned and store
+  the query and the generated advice string either in local or shared memory.
+  Note that this extension requires the <xref linkend="pgplanadvice" /> module,
+  which performs the actual plan advice generation; this module only knows
+  how to store the generated advice for later examination. Whenever
+  <literal>pg_collect_advice</literal> is loaded, it will automatically load
+  <literal>pg_plan_advice</literal>.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_collect_advice</literal> in at least
+  one database, so that you have a way to examine the collected advice.
+  You will also need the <literal>pg_collect_advice</literal> module
+  to be loaded in all sessions where advice is to be collected. It will
+  usually be best to do this by adding <literal>pg_collect_advice</literal>
+  to <xref linkend="guc-shared-preload-libraries"/> and restarting the
+  server.
+ </para>
+
+ <para>
+  <literal>pg_collect_advice</literal> includes both a shared advice
+  collector and a local advice collector. The local advice collector makes
+  queries and their advice strings visible only to the session where those
+  queries were planned, while the shared advice collector collects data
+  on a system-wide basis, and authorized users can examine data from all
+  sessions.
+ </para>
+
+ <para>
+  To enable a collector, you must first set a collection limit. When the
+  number of queries for which advice has been stored exceeds the collection
+  limit, the oldest queries and the corresponding advice will be discarded.
+  Then, you must adjust a separate setting to actually enable advice
+  collection. For the local collector, set the collection limit by configuring
+  <literal>pg_collect_advice.local_collection_limit</literal> to a value
+  greater than zero, and then enable advice collection by setting
+  <literal>pg_collect_advice.local_collector = true</literal>. For the shared
+  collector, the procedure is the same, except that the names of the settings
+  are <literal>pg_collect_advice.shared_collection_limit</literal> and
+  <literal>pg_collect_advice.shared_collector</literal>. Note that in both
+  cases, query texts and advice strings are stored in memory, so
+  configuring large limits may result in considerable memory consumption.
+ </para>
+
+ <para>
+  Once the collector is enabled, you can run any queries for which you wish
+  to see the generated plan advice. Then, you can examine what has been
+  collected using whichever of
+  <literal>SELECT * FROM pg_get_collected_local_advice()</literal> or
+  <literal>SELECT * FROM pg_get_collected_shared_advice()</literal>
+  corresponds to the collector you enabled. To discard the collected advice
+  and release memory, you can call
+  <literal>pg_clear_collected_local_advice()</literal>
+  or <literal>pg_clear_collected_shared_advice()</literal>.
+ </para>
+
+ <para>
+  In addition to the query texts and advice strings, the advice collectors
+  will also store the OID of the role that caused the query to be planned,
+  the OID of the database in which the query was planned, the query ID,
+  and the time at which the collection occurred. This module does not
+  automatically enable query ID computation; therefore, if you want the
+  query ID value to be populated in collected advice, be sure to configure
+  <literal>compute_query_id = on</literal>. Otherwise, the query ID may
+  always show as <literal>0</literal>.
+ </para>
+
+ <sect2 id="pgcollectadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_local_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from backend-local
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_local_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the local
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_shared_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from shared
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_shared_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the shared
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collector</varname> enables the
+      local advice collector. The default value is <literal>false</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      local advice collector. The default value is <literal>0</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collector</varname> enables the
+      shared advice collector. The default value is <literal>false</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      shared advice collector. The default value is <literal>0</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 52f8603a7be..7a233a88a09 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4003,6 +4003,12 @@ pg_uuid_t
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgca_collected_advice
+pgca_local_advice
+pgca_local_advice_chunk
+pgca_shared_advice
+pgca_shared_advice_chunk
+pgca_shared_state
 pgpa_advice_item
 pgpa_advice_tag_type
 pgpa_advice_target
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-18 17:07  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-18 17:07 UTC (permalink / raw)
  To: PostgreSQL Hackers <[email protected]>; Tom Lane <[email protected]>

Hi,

Coverity complained about the fact that pgpa_planner_feedback_warning
does not do anything to free the memory used by flagbuf or detailbuf,
and Tom asked whether that failure could amount to a significant leak.
The best way to use a lot of memory here is to have a very long advice
string that doesn't do anything useful, so I created this test case:

load 'pg_plan_advice';
select set_config('pg_plan_advice.advice', v.v, false) from (select
string_agg('SEQ_SCAN(hogehogehoge' || g || ')', ' ') v from
generate_series(1,10000) g) v;
set pg_plan_advice.feedback_warnings = true;
select 1;

While this is not the worst case imaginable, it's pretty bad. There's
probably no use case for having 10000 advice items in your advice
string even if they were all valid, and here none of them are valid
for the final query (select 1). So this produces "WARNING:  supplied
plan advice was not enforced" with a 10000 line detail message where
all the lines look like this, but with different numbers:

advice SEQ_SCAN(hogehogehoge7348) feedback is "not matched"

Failure to free the buffers costs us just over 1MB of memory, which is
not wildly disproportionate given that the message itself is over half
a MB long, so I'm not sure that freeing it is all that useful here. I
don't think the context we use for planning normally sticks around for
all that long. If I'm wrong about that and it does, then we should
probably wrap our own shorter-lived context around the stuff this
module is doing rather than trying to free allocations individually.

But this does raise two points which are perhaps worthy of some
further consideration:

1. Maybe we should limit the length of the detail message. In some
other cases, like reportDependentObjects, we limit the detail message
to the first 100 problems found, and then say at the end of that
detail message how many more problems there were afterwards. We could
do that here, too. I'm not 100% sure it's worth the code, but then
again it's not much code.

2. The way the current code works is to transform the advice feedback
into a Node-tree representation which is stashed in the PlannedStmt's
extension_state, and then also passes that Node-tree representation to
pgpa_planner_feedback_warning, which generates the actual warning.
That Node-tree representation is currently used by EXPLAIN to display
the advice feedback, which I think for most people will be a nicer
interface than enabling pg_plan_advice.feedback_warnings, and it can
also be used by other extensions. For instance, we could extend
pg_stash_advice so that it looks at the advice feedback and updates
the shared store, so that users can monitor whether their stashed
advice is doing what they hoped it would.

However, in a case like this, that Node tree is actually quite large:
about 16MB. I guess that's because pgpa_planner_append_feedback() has
to do multiple allocations for every item of feedback: a C string,
DefElem, an Integer, plus whatever lappend() charges us to add to a
List. We could save that memory by adding code here to optimize for
the case where we need to generate warnings but we don't need the Node
tree for anything else. I'm inclined not to do that, because (A) I
don't think temporarily using 16MB when the user specifies 10,000
items of bogus advice is really that bad and (B) complicating the code
has its own costs, such as maybe introducing more bugs. However, maybe
someone else sees it differently.

Another idea is to try to find a more economic Node representation.
For instance, we could jam the flags into the DefElem's location
field, instead of building a separate Integer node, or we could build
the advice feedback as a giant binary blob and wrap it in a varlena
and a Const node and leave consumers to make sense of that as best
they're able, or we could invent a new Node type that's just to the
perfect thing to hold a C string and an integer. I'm inclined to think
that the first two are too hacky and the third is too special-purpose,
but again someone else might see it otherwise.

Thoughts?

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-18 18:59  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-03-18 18:59 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>; Tom Lane <[email protected]>

On Wed, Mar 18, 2026 at 10:08 AM Robert Haas <[email protected]> wrote:
> But this does raise two points which are perhaps worthy of some
> further consideration:
>
> 1. Maybe we should limit the length of the detail message. In some
> other cases, like reportDependentObjects, we limit the detail message
> to the first 100 problems found, and then say at the end of that
> detail message how many more problems there were afterwards. We could
> do that here, too. I'm not 100% sure it's worth the code, but then
> again it's not much code.

I think we have similar problems elsewhere in Postgres where a large
user input causes an even larger log output - e.g. a case I'm familiar
with are complicated queries with long IN list inputs and their
associated EXPLAIN plans being logged by auto_explain - I recently had
a case where someone reported an OOM due to auto_explain trying to log
a > 100 MB sized query plan.

I think its actually less a problem with plan advice, since presumably
we won't have ORMs generating plan advice, and even if we do it'd be
per-table - so I think its unlikely a genuine use case would use 1000s
of advice tags.

That said, I also don't think super long long messages are actually
helpful. I do wonder if we should have a more coarse GUC that limits
DETAIL lines of any kind to a maximum length (e.g. 100 kB) across the
board instead of special casing every emitter.

> 2. The way the current code works is to transform the advice feedback
> into a Node-tree representation which is stashed in the PlannedStmt's
> extension_state, and then also passes that Node-tree representation to
> pgpa_planner_feedback_warning, which generates the actual warning.
> That Node-tree representation is currently used by EXPLAIN to display
> the advice feedback, which I think for most people will be a nicer
> interface than enabling pg_plan_advice.feedback_warnings, and it can
> also be used by other extensions. For instance, we could extend
> pg_stash_advice so that it looks at the advice feedback and updates
> the shared store, so that users can monitor whether their stashed
> advice is doing what they hoped it would.

Yeah, I think the ability for other extensions to retrieve this is
pretty important - whether in pg_stash_advice, or any other kind of
plan management extension that wants to know the outcome of applied
advice.

>
> However, in a case like this, that Node tree is actually quite large:
> about 16MB. I guess that's because pgpa_planner_append_feedback() has
> to do multiple allocations for every item of feedback: a C string,
> DefElem, an Integer, plus whatever lappend() charges us to add to a
> List. We could save that memory by adding code here to optimize for
> the case where we need to generate warnings but we don't need the Node
> tree for anything else. I'm inclined not to do that, because (A) I
> don't think temporarily using 16MB when the user specifies 10,000
> items of bogus advice is really that bad and (B) complicating the code
> has its own costs, such as maybe introducing more bugs. However, maybe
> someone else sees it differently.
>
> Another idea is to try to find a more economic Node representation.
> For instance, we could jam the flags into the DefElem's location
> field, instead of building a separate Integer node, or we could build
> the advice feedback as a giant binary blob and wrap it in a varlena
> and a Const node and leave consumers to make sense of that as best
> they're able, or we could invent a new Node type that's just to the
> perfect thing to hold a C string and an integer. I'm inclined to think
> that the first two are too hacky and the third is too special-purpose,
> but again someone else might see it otherwise.

Yeah, I feel like we're definitely constrained here by the fact that
advice tags are defined by a contrib module vs in-core - if they were
in-core we could just add a dedicated node type for them. I don't
think inventing a specialized binary format only defined in the
contrib module makes sense.

Two other ideas:

1) What if we return the utilized advice string as a separate DefElem
with a list of strings, and then the feedback just has to reference an
index into that list? (though I suppose that doesn't actually save
memory, now that I think that through -- unless we assume the caller
already has the advice string, but I don't think we can rely on that)

2) We could consider having separate DefElems for the different flag
types (i.e. "feedback_failed", "feedback_match_full", etc), and then a
list of strings attached to each - that'd save the nested DefElem and
the Integer node

Thanks,
Lukas

-- 
Lukas Fittl





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-18 22:19  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-18 22:19 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Wed, Mar 18, 2026 at 4:44 PM Tom Lane <[email protected]> wrote:
> Don't know if you noticed yet, but avocet has shown [1] that one
> pg_plan_advice test case is unstable under debug_discard_caches = 1:

I had not. Thanks for the pointer.

> It looks like the appearance of "Supplied Plan Advice:" depends
> on whether the prepared query's plan got regenerated or not.
> I'm not sure if this represents a bug (ie undesirable behavior) or
> it's just that the test is being insufficiently careful about
> being reproducible.

Well, that's embarrassing: it's a copy-and-paste error. The test
prepares and executes pt1, then prepares and executes pt2, then
prepares pt3 and executes pt1, then prepares pt4 and execute p2. pt3
and pt4 are never used for anything. Also there's a related typo in a
comment. See attached.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v1-0001-pg_plan_advice-Fix-multiple-copy-and-paste-errors.nocfbot (2.6K, 2-v1-0001-pg_plan_advice-Fix-multiple-copy-and-paste-errors.nocfbot)
  download

^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-18 22:34  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-18 22:34 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Wed, Mar 18, 2026 at 6:26 PM Tom Lane <[email protected]> wrote:
> > Well, that's embarrassing: it's a copy-and-paste error. The test
> > prepares and executes pt1, then prepares and executes pt2, then
> > prepares pt3 and executes pt1, then prepares pt4 and execute p2. pt3
> > and pt4 are never used for anything. Also there's a related typo in a
> > comment. See attached.
>
> Ah ... so the observed behavior is because if pt2 does get replanned,
> that happens with a different always_store_advice_details setting
> than it used originally?

Close, but not quite. always_store_advice_details is set for the
even-numbered tests, so it's the same for pt2 and pt4. But
pg_plan_advice.advice is empty for the the first half of the file and
set for the second half of the file, so it's empty when pt2 is
prepared and contains SEQ_SCAN(ptab) when pt4 is prepared. That
accounts for the difference. avocet is actually doing the right thing,
and the rest of the buildfarm is doing the wrong thing for lack of
replanning.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-18 22:57  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-18 22:57 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Wed, Mar 18, 2026 at 6:43 PM Tom Lane <[email protected]> wrote:
> Well, avocet is producing the output you want, but I think the rest
> are behaving correctly given the way the script is written.

Yeah, this goes back to my "that's embarrassing" comment: obviously, I
really did not proofread this well at all.

> Anyway, I confirm that the patched output is stable with
> debug_discard_caches = 1, so LGTM.

Yeah, I also tested that here. Glad it works that way for you also.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-19 16:54  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-19 16:54 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>; Tom Lane <[email protected]>

On Wed, Mar 18, 2026 at 3:00 PM Lukas Fittl <[email protected]> wrote:
> I think we have similar problems elsewhere in Postgres where a large
> user input causes an even larger log output - e.g. a case I'm familiar
> with are complicated queries with long IN list inputs and their
> associated EXPLAIN plans being logged by auto_explain - I recently had
> a case where someone reported an OOM due to auto_explain trying to log
> a > 100 MB sized query plan.
>
> I think its actually less a problem with plan advice, since presumably
> we won't have ORMs generating plan advice, and even if we do it'd be
> per-table - so I think its unlikely a genuine use case would use 1000s
> of advice tags.
>
> That said, I also don't think super long long messages are actually
> helpful. I do wonder if we should have a more coarse GUC that limits
> DETAIL lines of any kind to a maximum length (e.g. 100 kB) across the
> board instead of special casing every emitter.

I think it would be difficult for generic code to do something
sensible when the message is really long. I mean, it could just cut it
off after N lines, but then you have no idea how much more there would
have been, and you probably want to tell the user something about
that. You could add a completely generic message to the end like "plus
10525 more lines of output that were truncated for display," but
that's pretty unsatisfying. If you want to show something contextually
appropriate, the implementation needs to be separate for each case
even if the limit is common. Anyway, the question here is not about
such a generic mechanism, but about whether somebody wants to argue
for sticking a limit of 100 on feedback messages on the theory that
log spam is bad, or whether it's fine as-is either because (a) the
likelihood of a significant number of people hitting that limit is
thought to be too low to worry about or (b) the likelihood of someone
wanting all of those messages (e.g. for machine-parsing) is thought to
be high enough that a limit is actually worse than no limit. I do not
really have a horse in the race, so if nobody else has a strong
opinion, I'm going to leave it alone for now and consider changing it
if a strong opinion materializes later.

> 1) What if we return the utilized advice string as a separate DefElem
> with a list of strings, and then the feedback just has to reference an
> index into that list? (though I suppose that doesn't actually save
> memory, now that I think that through -- unless we assume the caller
> already has the advice string, but I don't think we can rely on that)

I think that if we're using Integer nodes, it's just never going to be
very economical. We could use an IntList, which I believe would be
better, but there are a few complications that make me not like this
idea very much. One, what the feedback is actually about is a
reconstruction of a particular advice item into string form, not the
original string, e.g. if you input SEQ_SCAN(foo bar), the feedback
will be on SEQ_SCAN(foo) and SEQ_SCAN(bar). Two, even if you ignore
that, this would leave consumers of the data with the problem of
finding the end of the advice item unless you stored both starting and
ending indexes, which would have its own costs.

> 2) We could consider having separate DefElems for the different flag
> types (i.e. "feedback_failed", "feedback_match_full", etc), and then a
> list of strings attached to each - that'd save the nested DefElem and
> the Integer node

But it would also very often duplicate a bunch of the strings, which
seems likely to work out to a loss more often than not. You could
avoid that by have a list of strings per unique flag combination, but
that would be extra work to compute and I think it would be less
convenient for consumers. One user-visible consequence would be that
the advice feedback in EXPLAIN output would have much less to do with
the original order of the advice string.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-19 17:17  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 2 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-19 17:17 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Thu, Mar 19, 2026 at 12:46 PM Tom Lane <[email protected]> wrote:
> I just realized that this new test module has added yet another
> execution of the entire core regression test suite ... and to add
> insult to injury, it runs the planner twice for each plannable
> query therein.  I think this is an outrageous abuse of our buildfarm
> and urgently request that you find some less climate-destroying
> way of getting reasonable test coverage of pg_plan_advice.

I don't think just nuking this is a reasonable option. During the
development of pg_plan_advice, test_plan_advice was the single most
valuable testing tool. It was not close. Manual review, both by me and
by others, found bugs; fuzz testing by Jacob Champion found bugs; AI
code review found bugs. test_plan_advice was an order of magnitude
more effective than all of those things put together. In fact, I'd say
maybe two orders of magnitude. Pretty much every significant logical
error was something that required a very specific query shape to find,
and the regression tests are by far the best repository of weird query
shapes that we have.

I think without something like test_plan_advice, the chances of us
noticing if future planner changes break pg_plan_advice are near zero,
and with it, they're quite good -- because we're not only testing the
queries that exist in the regression test suite now, but we're testing
future queries that are added to the regression test suite by people
who are hacking on the planner. Those added queries presumably create
plan shapes that are relevant to the code that they're changing,
whereas a fixed pg_plan_advice-only works if people think to add
something to it, which they very likely won't, and even if they do, I
have no confidence that they'll know what things to add and what not.
I certainly didn't know which queries in the regression tests were
going to break under test_plan_advice until I tried it.

One thing we might be able to do here to save on cycles is combine
test_plan_advice with some existing run of the regression tests. We
seem to run them to test sepgsql, to test pg_upgrade, and in
027_stream_regress.pl. I don't think we can piggyback on sepgsql here
because there are too many cases where it just won't be built or
tested, but we could possibly piggyback on one of the other runs, or
on the main regression tests. If that's not viable, another option
would be to have it run on only some buildfarm members rather than all
of them.  But if the tests aren't run by default, then I fear people
will experience quite a bit frustration the first time something
breaks only after they commit.

Before I forget, another idea that might help is to see if we can
tweak meson.build to start running this particular test earlier. I
thought about that during development, but I didn't actually do it. If
the issue is that test being last to finish, that could help. If the
issue is total resource consumption, it won't help with that.

> I would dig into why grison and schnauzer are failing this test,
> except that I don't agree that we should be running it in the
> first place.

I'll go have a look.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-19 20:38  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  1 sibling, 2 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-19 20:38 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Thu, Mar 19, 2026 at 1:17 PM Robert Haas <[email protected]> wrote:
> > I would dig into why grison and schnauzer are failing this test,
> > except that I don't agree that we should be running it in the
> > first place.
>
> I'll go have a look.

grison failed like this:

 CREATE INDEX ON vaccluster(wrap_do_analyze(i));
+ERROR:  indexes on virtual generated columns are not supported

This is a surprising diff, because either that command is trying to
create an index on a virtual generated column, or it's not, and it
either is or isn't for every machine in the buildfarm and under
test_plan_advice or not. The problem might be that the first
TupleDescAttr(...) == ATTRIBUTE_GENERATED_VIRTUAL test in DefineIndex
can be reached with attno == 0, which seems like it will then access
memory that is not part of the TupleDesc. If that memory happens to
contain a 'v' in the right spot, this will happen. (Is it not viable
to have TupleDescAttr() assert that the second argument is >= 0?)

skink has a failure that looks like this:

+WARNING:  supplied plan advice was not enforced
+DETAIL:  advice NESTED_LOOP_MEMOIZE(nt) feedback is "matched, failed"

I think this is caused by a minor bug in the pgs_mask infrastructure.
get_memoize_path() exits quickly when outer_path->parent->rows < 2, on
the theory that the resulting path must lose on cost. But that
presumes that we could do a plain nested loop instead, i.e. that
PGS_NESTLOOP_PLAIN is set. And it might not be. Before the pgs_mask
stuff, that case couldn't happen: enable_nestloop=off disabled all
nested loops, and enable_memoize=off disabled only the memoized
version, but there wasn't any way to disable only the non-memoized
version (which, of course, was part of the point of the whole thing).
I think the fix is as attached.

Unfortunately, the other failures look like they are pointing to a
rather more serious problem. schnauzer's got a failure that looks like
this:

+WARNING:  supplied plan advice was not enforced
+DETAIL:  advice SEQ_SCAN(pg_trigger@exists_1) feedback is "matched, failed"
+advice NO_GATHER(pg_trigger@exists_1) feedback is "matched, failed"

And an older run on skink has a failure that looks like this:

+WARNING:  supplied plan advice was not enforced
+DETAIL:  advice SEQ_SCAN(pg_trigger@exists_6) feedback is "matched, failed"
+advice NO_GATHER(pg_trigger@exists_6) feedback is "matched, failed"

What these failures have in common is that both of them involve
selecting from information_schema.views. What "matched, failed" means
is that we saw the advice target during planning and tried to
influence the plan, but then observed that the final plan doesn't
respect the supplied advice. The query for information_schema.views
involves three subplans, so the fact that exists_6 is mentioned here
is a strong hint that the AlternativeSubPlan machinery is in play
here. The query is planned once, and one of the two alternative
subplans is chosen, generating advice for that plan. Upon replanning,
the other plan is chosen, so the subplan for which we have plan advice
doesn't appear in the query at all, leading to this. Now, you might
wonder how that's possible, considering that we're planning the same
query twice in a row with advice that isn't supposed to change
anything. My guess is that it's possible because these machines are
slow and other tests are running concurrently. If those other sessions
execute DDL, they can send sinval messages, which can cause the second
planning cycle to see different statistics than the first one. That
then means the plan can change in any way except for what the advice
system already knows how to control, and choice of AlternativeSubPlan
is not in that set of things. I think I actually saw a failure similar
to this once or twice locally during development, but that was back
when the code had a lot of bugs, and I assumed that the failure was
caused by some transient bug in whatever changes I was hacking on at
the time, or some other bug that I fixed later, rather than being a
real issue. I think the reason it doesn't happen very often is because
the statistics have to change enough at just the right moment and even
on slower buildfarm machines, most of the time, they don't.

It's not really clear to me exactly where to place the blame for this
category of failure. One view is that tests are being run in parallel
and I didn't think about that, and therefore this is a defect in the
test methodology that needs to be rectified somehow (hopefully not by
throwing it out). We might be able to fix that by running the test
suite serially rather than in parallel, although I expect that since
you (Tom) are already unhappy with the time this takes, that will
probably go over like a lead balloon. Another angle is to blame this
on the decision to assign different plan names to the different
subroots that we use for the alternative subplans. If we used
exists_1, exists_2, and exists_3 twice each instead of
exists_1..exists_6, this wouldn't happen, though that idea seems
questionable on theoretical grounds. A third possible take is that not
including choice-of-alternative-subplan in the initial scope was an
error.

As of this moment, I'm not really sure which way to jump. I need to
think further about what to do about this one. We can continue the
discussion about reducing the cost at the same time; again, I am
definitely not saying that it isn't legitimate to be concerned about
the CPU cycles expended running these tests, but those CPU cycles have
found three separate problems in two days, which is not nothing.

Separately, I am now 100% convinced that I need to go revise the
pg_collect_advice patch, because that adds yet another run of the core
regression tests, but for much less possibility of any real gain. I'll
go get rid of that and figure out what, if anything, to replace it
with.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v1-0001-get_memoize_path-Don-t-exit-quickly-when-PGS_NEST.nocfbot (1.8K, 2-v1-0001-get_memoize_path-Don-t-exit-quickly-when-PGS_NEST.nocfbot)
  download

^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-19 21:33  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-19 21:33 UTC (permalink / raw)
  To: Matheus Alcantara <[email protected]>; +Cc: Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 19, 2026 at 4:12 PM Matheus Alcantara
<[email protected]> wrote:
> We can add a 'priority' for the test:
>
>    'tap': {
>      'tests': [
>        't/001_replan_regress.pl',
>      ],
> +   'test_kwargs': {'priority': 50},
>    },
>
> Even if it's not help with resource consumption I think that it still
> worth adding. It reduces from ~5m to ~4m on my machine.

I tested this out here. Without this change, 'meson test' takes
2:53-2:55 for me. After making the change, I got times from 2:53-2:56,
so basically no change. But I suspect your proposal here is still the
right thing to do. I wondered if it should actually do what
src/test/regress/meson.build does:

    'test_kwargs': {
      'priority': 50,
      'timeout': 1000,
    },

 ...but it seems as though the timeout for TAP tests is already 1000s,
so maybe we don't need to change anything. Or maybe the recent
"timedout" errors in the buildfarm are a sign that 1000s isn't long
enough for this more-intensive run:

https://buildfarm.postgresql.org/cgi-bin/show_failures.pl?max_days=3&stage=timedout&filter=S...

If so, that would be sad. On my local machine, which is a ~3 yo
MacBook, running just the "regress" test suite takes ~11.1 s, and
running just the "test_plan_advice" suite takes ~12s, so I admit that
I'm slightly confused about why this is having such a big impact for
you and Tom. Obviously I'm not running with expensive options like
debug_discard_caches or Valgrind enabled, but presumably you're not
doing that locally either and it still shaves a minute off the runtime
for you. What exactly is different, I'm not entirely sure.

Anyway, I think I should still go make your suggested change, unless
somebody objects. We may change more later, but if this provides some
relief to some people for now, it seems worth doing.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-19 22:19  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-19 22:19 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 19, 2026 at 6:03 PM Tom Lane <[email protected]> wrote:
> Please note that I was citing the runtime of a much slower machine
> (longfin is a 2018 mac mini).  But in any case, what I was griping
> about was the additional cost added to a buildfarm run; I don't see
> that test_plan_advice is a lot slower than the main regression tests.
> It's just that those are already a significant investment, and we
> just iterated them another time.

Right, and there's definitely plenty of worthless crap in there that
isn't adding any value. For example, every \dWHATEVER command in the
regression test is running basically the same queries every time, and
after the first time we're probably not learning anything new. And all
the DDL commands that are part of the regression tests are fairly
useless here. The grison failure is actually triggered by a DDL
command, but I think that might just be luck rather than the
test_plan_advice module is doing anything to systematically increase
the likelihood of such findings. But I don't know how we can separate
the wheat from the chaff. Obviously a lot of the DDL and \d commands
in the tests are either setup for queries that we should care about
testing, or verification that those queries did what they were
supposed to do. If we split the main regression test suite up, so that
we had one test suite for planner-related stuff, another for DDL, and
another for data types, or something like that, then test_plan_advice
would probably mostly just need to run on the first of those. But that
kind of split seems like a lot of work.

Do you think the idea of piggybacking the test_plan_advice run onto
another run that we're already doing has any potential? That would
reduce the incremental cost quite a lot, I think.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-19 23:11  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-19 23:11 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Matheus Alcantara <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 19, 2026 at 6:43 PM Tom Lane <[email protected]> wrote:
> It would, but it's conceptually ugly and it might make it much harder
> to detangle the cause of a failure, so I don't care for it much.

It does carry that risk. *Typically* failures are going to be a
WARNING message complaining about something related to advice, so the
chance of confusion is perhaps not as high as it would be in some
other cases -- but the grison failure is a counterexample. I'm
somewhat inclined to discount that particular counterexample because
the bug is entirely unrelated to test_plan_advice or pg_plan_advice,
so I am not sure it really would have mattered if we hadn't known that
test_plan_advice was what precipitated it. But there might be other
cases where that isn't so.

> I don't have any great ideas here.  Your point about the test having
> helped to find a lot of bugs is compelling, and so is the fact that
> it's seemingly exposing more issues we've not understood yet.
> Maybe we can eventually buy back the cycles by not running it by
> default, but clearly now is not the time for that.

OK, thanks. To be honest, my biggest fear here is not that the test
doesn't have enough value, but that it has a little too much value,
i.e. that we're going to find that future planner improvements require
pg_plan_advice adjustments more often than we're all comfortable with.
Hopefully that fear is unjustified, but we're not going to know for a
while.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-20 16:06  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-20 16:06 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 19, 2026 at 1:02 PM Tom Lane <[email protected]> wrote:
> FWIW, I'm inclined to think that people won't actually have this
> turned on unless they want to see that output, and then they'll
> probably want to see all of it.  So I'm inclined to leave it as-is
> for now.  If field experience teaches differently, we can always
> improve it later.

Thanks for chiming in. I'll leave it as-is for the time being. My
guess is that this will be fairly low on the list of things people
complain about, but I'll be very happy if proven wrong, since this one
is easily changed.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-21 13:13  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-21 13:13 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Thu, Mar 19, 2026 at 4:38 PM Robert Haas <[email protected]> wrote:
> schnauzer's got a failure that looks like this:
>
> +WARNING:  supplied plan advice was not enforced
> +DETAIL:  advice SEQ_SCAN(pg_trigger@exists_1) feedback is "matched, failed"
> +advice NO_GATHER(pg_trigger@exists_1) feedback is "matched, failed"
>
> And an older run on skink has a failure that looks like this:
>
> +WARNING:  supplied plan advice was not enforced
> +DETAIL:  advice SEQ_SCAN(pg_trigger@exists_6) feedback is "matched, failed"
> +advice NO_GATHER(pg_trigger@exists_6) feedback is "matched, failed"

I spent a bunch of time looking into this. I don't have a definitive
answer to the question of what to do about it yet, but I wanted to
write down some initial thoughts.

First, I think that I broke fix_alternative_subplan when I added
disabled_nodes. Before, when disabling things just affected the cost,
the logic here would have taken what was disabled into account in
picking between alternatives. Now it doesn't, because I didn't add a
disabled_nodes field to SubPlan. You can see that it breaking with
this test case:

CREATE TABLE t1 (a int);
CREATE TABLE t2 (a int);
CREATE INDEX ON t2(a);
INSERT INTO t1 SELECT generate_series(1, 1000);
INSERT INTO t2 SELECT generate_series(1, 100000);
ANALYZE;

EXPLAIN (VERBOSE, COSTS ON)
SELECT * FROM t1
WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.a = t1.a) OR t1.a < 0;

SET enable_seqscan = off;
SET enable_indexonlyscan = off;

EXPLAIN (VERBOSE, COSTS ON)
SELECT * FROM t1
WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.a = t1.a) OR t1.a < 0;

With the currently-committed code, you get a hashed SubPlan both
times, and it's just disabled in the second case. But there's a
perfectly good non-hashed variant that is not disabled: scan t2 using
a parameterized Index Scan. So that sucks. I have a small patch to fix
this, which I'll post later. I don't think we can or should do
anything about this in released branches, but we should fix it in
master regardless of what happens to pg_plan_advice or
test_plan_advice.

Second, in terms of actually fixing the problem, I think the issue
here is that the scope I chose for pg_plan_advice doesn't quite fit
the goal of making test_plan_advice the canonical way of testing this
code. In order to keep this project manageable in size, I decided
that, for the first version, pg_plan_advice wasn't going to try to
control anything above the level of scan/join planning, so for example
we don't care what kind of aggregation the planner chooses. That
should be OK for test_plan_advice, because test_plan_advice works by
checking if all the advice applied cleanly, and since we didn't emit
any advice about the aggregation method in the first place, there
can't be any problem with applying it later. In other words, if the
aggregation method chosen does flip between consecutive planning
cycles, it should not cause a test_plan_advice test failure. This same
general principle applies to a bunch of other cases too, like set
operation planning: if there's more than one way to do it, the planner
can change its mind and test_plan_advice should not care. However,
there's an important exception: if something changes above the level
of scan/join planning that affects what rels are involved in scan/join
planning, then a plan change will cause a test_plan_advice failure.

The failures above are of that type: the way the AlternativeSubPlan
machinery works is that the query gets copied and replanned, and each
plan has a separate plan name. So the final plan has either
pg_trigger@exists_5 or pg_trigger@exists_6 in it, but never both.
There's one other case that I think is similar, which is the
MinMaxAggPath stuff: if we choose the MinMaxAggPath, then the final
plan will have something like t1@minmax_1 where it otherwise would
have had just t1, or perhaps t1@minmax_1 instead of t1@something_else.
These cases have something in common, which is that they are the only
cases where we make a new PlannerInfo to try replanning part of the
query in a second way. That's the pattern that causes breakage here. I
would be remiss if I did not mention that Jakub Wartak was poking at
me about the MinMaxAggPath case a while back, but I dismissed it as
out of scope, which was accurate, but that's because I didn't think at
the time that it would destabilize test_plan_advice. Now, I think it
could, although I don't think we have seen any such failures in the
buildfarm yet. Perhaps a concurrent statistics change is more likely
to flip the hashed/non-hashed SubPlan decision than the choice of
whether to use MinMaxAggPath.

One approach that I considered here is to try to unify the "sibling"
relations. If the final plan is bound to contain either
pg_trigger@exists_5 or pg_trigger@exists_6, maybe the advice shouldn't
actually think there are two separate things. Maybe instead of
subqueries exists_1 ... exists_6 we should end up with subqueries
exists_1 ... exists_3, with each name used twice. That amounts to
deciding that the patch to give each subplan a unique name got it
wrong. While this idea has some appeal, ultimately I think it's a
loser, because addressing the problem this way wouldn't actually fix
the test_plan_advice instability we're currently seeing, or at least
not necessarily. For example, in the test case earlier in this email,
INDEX_SCAN(t2@whatever) can only be applied if the non-hashed subplan
is chosen, because we won't consider a plan index scan to even be a
possibility for a full table scan. An index-only scan is considered,
but not a plain index scan. This means that even if we flattened the
rels in each pair of siblings together and called each pair by the
same name, we could still see failures to apply advice cleanly.
Moreover, that's assuming that optimizations like self-join
elimination, left join removal, and partition pruning always apply in
the same way to both copies, which doesn't sound like a safe
assumption at all.

A second approach is to try to control the initial conditions of the
two planning cycles better. Possibly just running the tests serially
instead of in parallel would get that done, but that seems too slow to
consider and, even if we did it anyway, I'm not all that sure that an
autoanalyze or autovacuum in the background couldn't mess us up. Or,
alternatively, if we could keep the second planning cycle from seeing
different statistics than the first, I think that would do it, but I
don't think there's a feasible way of doing that.

So I'm left with the idea that to get test_plan_advice to be fully
stable on these slower machines, it will probably be necessary to make
it control which AlternativeSubPlan is chosen and whether a
MinMaxAggPath is chosen or not. I have some ideas about how to
accomplish that in a reasonably elegant fashion without adding too
much new machinery, but I need to spend some more time validating
those ideas before committing to a precise course of action. More
soon.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-24 20:47  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-24 20:47 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Thu, Mar 19, 2026 at 4:38 PM Robert Haas <[email protected]> wrote:
> skink has a failure that looks like this:
>
> +WARNING:  supplied plan advice was not enforced
> +DETAIL:  advice NESTED_LOOP_MEMOIZE(nt) feedback is "matched, failed"
>
> I think this is caused by a minor bug in the pgs_mask infrastructure.
> get_memoize_path() exits quickly when outer_path->parent->rows < 2, on
> the theory that the resulting path must lose on cost. But that
> presumes that we could do a plain nested loop instead, i.e. that
> PGS_NESTLOOP_PLAIN is set. And it might not be. Before the pgs_mask
> stuff, that case couldn't happen: enable_nestloop=off disabled all
> nested loops, and enable_memoize=off disabled only the memoized
> version, but there wasn't any way to disable only the non-memoized
> version (which, of course, was part of the point of the whole thing).
> I think the fix is as attached.

The new test in that version was exactly backwards. I have corrected
that issue and committed this.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-24 21:09  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-24 21:09 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: PostgreSQL Hackers <[email protected]>

On Sat, Mar 21, 2026 at 9:13 AM Robert Haas <[email protected]> wrote:
> So I'm left with the idea that to get test_plan_advice to be fully
> stable on these slower machines, it will probably be necessary to make
> it control which AlternativeSubPlan is chosen and whether a
> MinMaxAggPath is chosen or not. I have some ideas about how to
> accomplish that in a reasonably elegant fashion without adding too
> much new machinery, but I need to spend some more time validating
> those ideas before committing to a precise course of action. More
> soon.

Here is v22. There are four new patches.

0001 adds a disabled_nodes fields to SubPlan, to fix the bug that I
identified in the email to which this is a reply.

0002-0004 are an attempt to fix the remaining buildfarm failures not
already addressed (or attempted to be addressed, anyway) by other
commits. The basic idea, implemented by 0004, is to add a
DO_NOT_SCAN() advice tag. This advice is generated when we consider a
MinMaxAggPath or a hashed SubPlan. In either case, all relations which
are part of the non-selected alternative are marked DO_NOT_SCAN(),
which works like scan type advice but disables every possible scan
type rather than still allowing exactly one of them. Unless I've
missed something, this should be sufficient to make pg_plan_advice
stabilize which of two alternative SubPlans we pick and whether or not
a min/max aggregate is chosen. 0002 does some preliminary refactoring
to provide a more centralized way of tracking per-PlannerInfo details
within pg_plan_advice. 0003 makes the necessary change to
src/backend/optimizer, which consists of adding an alternative_root
field to each PlannerInfo and setting it appropriately. 0004 then
updates pg_plan_advice to implement DO_NOT_SCAN().

0005 is the pg_collect_advice module from previous versions of the
patch set. The main change here is that I completely rewrote the TAP
test, which previously was running the entire regression test suite
yet another time. That's been replaced with something that is much
faster and much better targeted at properly testing the shared advice
collector. Aside from that, I added one more check for
InvalidDsaPointer where the code was previously lacking one.

0006 is the pg_stash_advice module from previous versions of the patch
set. I have adjusted this to be much safer against permanent DSA
leaks. It now uses dshash_find_or_insert_extended instead of relying
on the ability to dshash_find a just-inserted entry without error. It
now also holds an LWLock while inserting or updating an entry in the
dshash table, for reasons explained in the comments. On the other
hand, it no longer unnecessarily holds the LWLock in exclusive mode
when looking up advice strings for automatic application, which was a
rather silly mistake in the previous version. A few additional tests
have been added. Alphabetization in contrib/Makefile has been fixed.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v22-0003-Add-an-alternative_root-field-to-PlannerInfo.patch (8.8K, 2-v22-0003-Add-an-alternative_root-field-to-PlannerInfo.patch)
  download | inline diff:
From 9bc42f4dbb4afbe6f69439b929f9ec0c7501277e Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 23 Mar 2026 08:49:44 -0400
Subject: [PATCH v22 3/6] Add an alternative_root field to PlannerInfo.

Typically, we have only one PlannerInfo for any given subquery, but
when we are considering a MinMaxAggPath or a hashed subplan, we end
up creating a second PlannerInfo for the same portion of the query,
with a clone of the original range table. In fact, in the MinMaxAggPath
case, we might end up creating several clones, one per aggregate.

At present, there's no easy way for a plugin, such as pg_plan_advice,
to understand the relationships between the original range table and
the copies of it that are created in these cases.  To fix, add an
alternative_root field to PlannerInfo. For a hashed subplan, this
points back to the PlannerInfo for the non-hashed alternative; for
minmax aggregates, this points back to the parent PlannerInfo; in
other cases, it's just NULL.
---
 src/backend/optimizer/path/allpaths.c     |  2 +-
 src/backend/optimizer/plan/planagg.c      |  1 +
 src/backend/optimizer/plan/planner.c      | 12 ++++++++----
 src/backend/optimizer/plan/subselect.c    |  6 +++---
 src/backend/optimizer/prep/prepjointree.c |  1 +
 src/backend/optimizer/prep/prepunion.c    |  2 +-
 src/include/nodes/pathnodes.h             | 11 +++++++++++
 src/include/optimizer/planner.h           |  1 +
 8 files changed, 27 insertions(+), 9 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index c26f48edfa0..61093f222a1 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -2833,7 +2833,7 @@ set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 	/* Generate a subroot and Paths for the subquery */
 	plan_name = choose_plan_name(root->glob, rte->eref->aliasname, false);
 	rel->subroot = subquery_planner(root->glob, subquery, plan_name,
-									root, false, tuple_fraction, NULL);
+									root, NULL, false, tuple_fraction, NULL);
 
 	/* Isolate the params needed by this specific subplan */
 	rel->subplan_params = root->plan_params;
diff --git a/src/backend/optimizer/plan/planagg.c b/src/backend/optimizer/plan/planagg.c
index 09b38b2c378..559a8c14e88 100644
--- a/src/backend/optimizer/plan/planagg.c
+++ b/src/backend/optimizer/plan/planagg.c
@@ -340,6 +340,7 @@ build_minmax_path(PlannerInfo *root, MinMaxAggInfo *mminfo,
 	memcpy(subroot, root, sizeof(PlannerInfo));
 	subroot->query_level++;
 	subroot->parent_root = root;
+	subroot->alternative_root = root;
 	subroot->plan_name = choose_plan_name(root->glob, "minmax", true);
 
 	/* reset subplan-related stuff */
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 42604a0f75c..9dcf49c0055 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -515,8 +515,8 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 							   &tuple_fraction, es);
 
 	/* primary planning entry point (may recurse for subqueries) */
-	root = subquery_planner(glob, parse, NULL, NULL, false, tuple_fraction,
-							NULL);
+	root = subquery_planner(glob, parse, NULL, NULL, NULL, false,
+							tuple_fraction, NULL);
 
 	/* Select best Path and turn it into a Plan */
 	final_rel = fetch_upper_rel(root, UPPERREL_FINAL, NULL);
@@ -715,6 +715,8 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
  * parse is the querytree produced by the parser & rewriter.
  * plan_name is the name to assign to this subplan (NULL at the top level).
  * parent_root is the immediate parent Query's info (NULL at the top level).
+ * alternative_root is a previously created PlannerInfo for which this query
+ * level is an alternative implementation, or else NULL.
  * hasRecursion is true if this is a recursive WITH query.
  * tuple_fraction is the fraction of tuples we expect will be retrieved.
  * tuple_fraction is interpreted as explained for grouping_planner, below.
@@ -741,8 +743,9 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
  */
 PlannerInfo *
 subquery_planner(PlannerGlobal *glob, Query *parse, char *plan_name,
-				 PlannerInfo *parent_root, bool hasRecursion,
-				 double tuple_fraction, SetOperationStmt *setops)
+				 PlannerInfo *parent_root, PlannerInfo *alternative_root,
+				 bool hasRecursion, double tuple_fraction,
+				 SetOperationStmt *setops)
 {
 	PlannerInfo *root;
 	List	   *newWithCheckOptions;
@@ -759,6 +762,7 @@ subquery_planner(PlannerGlobal *glob, Query *parse, char *plan_name,
 	root->query_level = parent_root ? parent_root->query_level + 1 : 1;
 	root->plan_name = plan_name;
 	root->parent_root = parent_root;
+	root->alternative_root = alternative_root;
 	root->plan_params = NIL;
 	root->outer_params = NULL;
 	root->planner_cxt = CurrentMemoryContext;
diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c
index 0d31861da7f..ccec1eaa7fe 100644
--- a/src/backend/optimizer/plan/subselect.c
+++ b/src/backend/optimizer/plan/subselect.c
@@ -224,7 +224,7 @@ make_subplan(PlannerInfo *root, Query *orig_subquery,
 	/* Generate Paths for the subquery */
 	subroot = subquery_planner(root->glob, subquery,
 							   choose_plan_name(root->glob, sublinkstr, true),
-							   root, false, tuple_fraction, NULL);
+							   root, NULL, false, tuple_fraction, NULL);
 
 	/* Isolate the params needed by this specific subplan */
 	plan_params = root->plan_params;
@@ -274,7 +274,7 @@ make_subplan(PlannerInfo *root, Query *orig_subquery,
 			/* Generate Paths for the ANY subquery; we'll need all rows */
 			plan_name = choose_plan_name(root->glob, sublinkstr, true);
 			subroot = subquery_planner(root->glob, subquery, plan_name,
-									   root, false, 0.0, NULL);
+									   root, subroot, false, 0.0, NULL);
 
 			/* Isolate the params needed by this specific subplan */
 			plan_params = root->plan_params;
@@ -971,7 +971,7 @@ SS_process_ctes(PlannerInfo *root)
 		 */
 		subroot = subquery_planner(root->glob, subquery,
 								   choose_plan_name(root->glob, cte->ctename, false),
-								   root, cte->cterecursive, 0.0, NULL);
+								   root, NULL, cte->cterecursive, 0.0, NULL);
 
 		/*
 		 * Since the current query level doesn't yet contain any RTEs, it
diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c
index d5e1041ffa3..8c0e6a61c96 100644
--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -1419,6 +1419,7 @@ pull_up_simple_subquery(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte,
 	subroot->query_level = root->query_level;
 	subroot->plan_name = root->plan_name;
 	subroot->parent_root = root->parent_root;
+	subroot->alternative_root = root->alternative_root;
 	subroot->plan_params = NIL;
 	subroot->outer_params = NULL;
 	subroot->planner_cxt = CurrentMemoryContext;
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index 583cb0b7a25..d1f022c5bfd 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -250,7 +250,7 @@ recurse_set_operations(Node *setOp, PlannerInfo *root,
 		 */
 		plan_name = choose_plan_name(root->glob, "setop", true);
 		subroot = rel->subroot = subquery_planner(root->glob, subquery,
-												  plan_name, root,
+												  plan_name, root, NULL,
 												  false, root->tuple_fraction,
 												  parentOp);
 
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 27758ec16fe..58ce19d1d21 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -317,6 +317,17 @@ struct PlannerInfo
 	/* NULL at outermost Query */
 	PlannerInfo *parent_root pg_node_attr(read_write_ignore);
 
+	/*
+	 * If this PlannerInfo exists to consider an alternative implementation
+	 * strategy for a portion of the query that could also be implemented by
+	 * some other PlannerInfo, this points to that other PlannerInfo. When
+	 * we are considering the first or only alternative, it is NULL.
+	 *
+	 * Currently, we use this when considering a MinMaxAggPath or a hashed
+	 * SubPlan.
+	 */
+	PlannerInfo *alternative_root pg_node_attr(read_write_ignore);
+
 	/* Subplan name for EXPLAIN and debugging purposes (NULL at top level) */
 	char	   *plan_name;
 
diff --git a/src/include/optimizer/planner.h b/src/include/optimizer/planner.h
index 80509773c01..9c4950b340f 100644
--- a/src/include/optimizer/planner.h
+++ b/src/include/optimizer/planner.h
@@ -63,6 +63,7 @@ extern PlannedStmt *standard_planner(Query *parse, const char *query_string,
 extern PlannerInfo *subquery_planner(PlannerGlobal *glob, Query *parse,
 									 char *plan_name,
 									 PlannerInfo *parent_root,
+									 PlannerInfo *alternative_root,
 									 bool hasRecursion, double tuple_fraction,
 									 SetOperationStmt *setops);
 
-- 
2.51.0



  [application/octet-stream] v22-0002-pg_plan_advice-Refactor-to-invent-pgpa_planner_i.patch (20.0K, 3-v22-0002-pg_plan_advice-Refactor-to-invent-pgpa_planner_i.patch)
  download | inline diff:
From 9436e9c94891e00c7db06bf9918fb68a4c451b66 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 24 Mar 2026 07:19:04 -0400
Subject: [PATCH v22 2/6] pg_plan_advice: Refactor to invent pgpa_planner_info

pg_plan_advice tracks two pieces of per-PlannerInfo data: (1) for each
RTI, the corresponding relation identifier, for purposes of
cross-checking those calculations against the final plan; and (2) the
set of semijoins seen during planning for which the strategy of making
one side unique was considered. The former is tracked using a hash
table that uses <plan_name, RTI> as the key, and the latter is
tracked using a List of <plan_name, relids>.

It seems better to track both of these things in the same way and
to try to reuse some code instead of having everything be completely
separate, so invent pgpa_planner_info; we'll create one every time we
see a new PlannerInfo and need to associate some data with it, and
we'll use the plan_name field to distinguish between PlannerInfo
objects, as it should always be unique. Then, refactor the two
systems mentioned above to use this new infrastructure.

(Note that the adjustment in pgpa_plan_walker is necessary in order
to avoid spuriously triggering the sanity check in that function,
in the case where a pgpa_planner_info is created for a purpose not
related to sj_unique_rels.)
---
 contrib/pg_plan_advice/pgpa_planner.c | 252 ++++++++++++--------------
 contrib/pg_plan_advice/pgpa_planner.h |  38 ++++
 contrib/pg_plan_advice/pgpa_walker.c  |  36 ++--
 contrib/pg_plan_advice/pgpa_walker.h  |  20 +-
 src/tools/pgindent/typedefs.list      |   4 +-
 5 files changed, 173 insertions(+), 177 deletions(-)

diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
index fee88904760..70139ff42be 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -34,53 +34,6 @@
 #include "parser/parsetree.h"
 #include "utils/lsyscache.h"
 
-#ifdef USE_ASSERT_CHECKING
-
-/*
- * When assertions are enabled, we try generating relation identifiers during
- * planning, saving them in a hash table, and then cross-checking them against
- * the ones generated after planning is complete.
- */
-typedef struct pgpa_ri_checker_key
-{
-	char	   *plan_name;
-	Index		rti;
-} pgpa_ri_checker_key;
-
-typedef struct pgpa_ri_checker
-{
-	pgpa_ri_checker_key key;
-	uint32		status;
-	const char *rid_string;
-} pgpa_ri_checker;
-
-static uint32 pgpa_ri_checker_hash_key(pgpa_ri_checker_key key);
-
-static inline bool
-pgpa_ri_checker_compare_key(pgpa_ri_checker_key a, pgpa_ri_checker_key b)
-{
-	if (a.rti != b.rti)
-		return false;
-	if (a.plan_name == NULL)
-		return (b.plan_name == NULL);
-	if (b.plan_name == NULL)
-		return false;
-	return strcmp(a.plan_name, b.plan_name) == 0;
-}
-
-#define SH_PREFIX			pgpa_ri_check
-#define SH_ELEMENT_TYPE		pgpa_ri_checker
-#define SH_KEY_TYPE			pgpa_ri_checker_key
-#define SH_KEY				key
-#define SH_HASH_KEY(tb, key)	pgpa_ri_checker_hash_key(key)
-#define	SH_EQUAL(tb, a, b)	pgpa_ri_checker_compare_key(a, b)
-#define SH_SCOPE			static inline
-#define SH_DECLARE
-#define SH_DEFINE
-#include "lib/simplehash.h"
-
-#endif
-
 typedef enum pgpa_jo_outcome
 {
 	PGPA_JO_PERMITTED,			/* permit this join order */
@@ -94,11 +47,8 @@ typedef struct pgpa_planner_state
 	bool		generate_advice_feedback;
 	bool		generate_advice_string;
 	pgpa_trove *trove;
-	List	   *sj_unique_rels;
-
-#ifdef USE_ASSERT_CHECKING
-	pgpa_ri_check_hash *ri_check_hash;
-#endif
+	List	   *proots;
+	pgpa_planner_info *last_proot;
 } pgpa_planner_state;
 
 typedef struct pgpa_join_state
@@ -211,6 +161,9 @@ static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
 										  pgpa_plan_walker_context *walker);
 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);
@@ -340,10 +293,6 @@ pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
 		pps->generate_advice_feedback = generate_advice_feedback;
 		pps->generate_advice_string = generate_advice_string;
 		pps->trove = trove;
-#ifdef USE_ASSERT_CHECKING
-		pps->ri_check_hash =
-			pgpa_ri_check_create(CurrentMemoryContext, 1024, NULL);
-#endif
 		SetPlannerGlobalExtensionState(glob, planner_extension_id, pps);
 	}
 
@@ -384,7 +333,7 @@ pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
 	 */
 	if (generate_advice_string || generate_advice_feedback)
 	{
-		pgpa_plan_walker(&walker, pstmt, pps->sj_unique_rels);
+		pgpa_plan_walker(&walker, pstmt, pps->proots);
 		rt_identifiers = pgpa_create_identifiers_for_planned_stmt(pstmt);
 	}
 
@@ -592,8 +541,7 @@ pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
 
 	/*
 	 * If we're considering implementing a semijoin by making one side unique,
-	 * make a note of it in the pgpa_planner_state. See comments for
-	 * pgpa_sj_unique_rel for why we do this.
+	 * make a note of it in the pgpa_planner_state.
 	 */
 	if (jointype == JOIN_UNIQUE_OUTER || jointype == JOIN_UNIQUE_INNER)
 	{
@@ -605,34 +553,18 @@ pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
 		if (pps != NULL &&
 			(pps->generate_advice_string || pps->generate_advice_feedback))
 		{
-			bool		found = false;
+			pgpa_planner_info *proot;
 
 			/* Avoid adding duplicates. */
-			foreach_ptr(pgpa_sj_unique_rel, ur, pps->sj_unique_rels)
+			proot = pgpa_planner_get_proot(pps, root);
+			if (!list_member(proot->sj_unique_rels, uniquerel->relids))
 			{
-				/*
-				 * We should always use the same pointer for the same plan
-				 * name, so we need not use strcmp() here.
-				 */
-				if (root->plan_name == ur->plan_name &&
-					bms_equal(uniquerel->relids, ur->relids))
-				{
-					found = true;
-					break;
-				}
-			}
-
-			/* If not a duplicate, append to the list. */
-			if (!found)
-			{
-				pgpa_sj_unique_rel *ur;
 				MemoryContext oldcontext;
 
+				/* Make sure to use a sufficiently long-lived context. */
 				oldcontext = MemoryContextSwitchTo(pps->mcxt);
-				ur = palloc_object(pgpa_sj_unique_rel);
-				ur->plan_name = root->plan_name;
-				ur->relids = bms_copy(uniquerel->relids);
-				pps->sj_unique_rels = lappend(pps->sj_unique_rels, ur);
+				proot->sj_unique_rels = lappend(proot->sj_unique_rels,
+												bms_copy(uniquerel->relids));
 				MemoryContextSwitchTo(oldcontext);
 			}
 		}
@@ -2017,34 +1949,64 @@ pgpa_planner_feedback_warning(List *feedback)
 				errdetail("%s", detailbuf.data));
 }
 
-#ifdef USE_ASSERT_CHECKING
-
 /*
- * Fast hash function for a key consisting of an RTI and plan name.
+ * Get or create the pgpa_planner_info for the subroot with the given
+ * plan_name.
  */
-static uint32
-pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
+static pgpa_planner_info *
+pgpa_planner_get_proot(pgpa_planner_state *pps, PlannerInfo *root)
 {
-	fasthash_state hs;
-	int			sp_len;
+	pgpa_planner_info *new_proot;
+
+	/*
+	 * If pps->last_proot isn't populated, there are no pgpa_planner_info
+	 * objects yet, so we can drop through and create a new one. Otherwise,
+	 * search for an object with a matching name, and drop through only if
+	 * none is found.
+	 */
+	if (pps->last_proot != NULL)
+	{
+		if (root->plan_name == NULL)
+		{
+			if (pps->last_proot->plan_name == NULL)
+				return pps->last_proot;
 
-	fasthash_init(&hs, 0);
+			foreach_ptr(pgpa_planner_info, proot, pps->proots)
+			{
+				if (proot->plan_name == NULL)
+				{
+					pps->last_proot = proot;
+					return proot;
+				}
+			}
+		}
+		else
+		{
+			if (pps->last_proot->plan_name != NULL &&
+				strcmp(pps->last_proot->plan_name, root->plan_name) == 0)
+				return pps->last_proot;
 
-	hs.accum = key.rti;
-	fasthash_combine(&hs);
+			foreach_ptr(pgpa_planner_info, proot, pps->proots)
+			{
+				if (proot->plan_name != NULL &&
+					strcmp(proot->plan_name, root->plan_name) == 0)
+				{
+					pps->last_proot = proot;
+					return proot;
+				}
+			}
+		}
+	}
 
-	/* plan_name can be NULL */
-	if (key.plan_name == NULL)
-		sp_len = 0;
-	else
-		sp_len = fasthash_accum_cstring(&hs, key.plan_name);
+	/* Create new object, add to list, and make it most recently used. */
+	new_proot = palloc0_object(pgpa_planner_info);
+	new_proot->plan_name = root->plan_name;
+	pps->proots = lappend(pps->proots, new_proot);
+	pps->last_proot = new_proot;
 
-	/* hashfn_unstable.h recommends using string length as tweak */
-	return fasthash_final32(&hs, sp_len);
+	return new_proot;
 }
 
-#endif
-
 /*
  * Save the range table identifier for one relation for future cross-checking.
  */
@@ -2053,19 +2015,34 @@ pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
 					 RelOptInfo *rel)
 {
 #ifdef USE_ASSERT_CHECKING
-	pgpa_ri_checker_key key;
-	pgpa_ri_checker *check;
-	pgpa_identifier rid;
-	const char *rid_string;
-	bool		found;
-
-	key.rti = bms_singleton_member(rel->relids);
-	key.plan_name = root->plan_name;
-	pgpa_compute_identifier_by_rti(root, key.rti, &rid);
-	rid_string = pgpa_identifier_string(&rid);
-	check = pgpa_ri_check_insert(pps->ri_check_hash, key, &found);
-	Assert(!found || strcmp(check->rid_string, rid_string) == 0);
-	check->rid_string = rid_string;
+	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)
+	{
+		int			new_size = Max(proot->rid_array_size, 8);
+
+		while (new_size < rel->relid)
+			new_size *= 2;
+
+		if (proot->rid_array_size == 0)
+			proot->rid_array = palloc0_array(pgpa_identifier, new_size);
+		else
+			proot->rid_array = repalloc0_array(proot->rid_array,
+											   pgpa_identifier,
+											   proot->rid_array_size,
+											   new_size);
+		proot->rid_array_size = new_size;
+	}
+
+	/* Save relation identifier details for this RTI if not already done. */
+	rid = &proot->rid_array[rel->relid - 1];
+	if (rid->alias_name == NULL)
+		pgpa_compute_identifier_by_rti(root, rel->relid, rid);
 #endif
 }
 
@@ -2078,26 +2055,22 @@ pgpa_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
 {
 #ifdef USE_ASSERT_CHECKING
 	pgpa_identifier *rt_identifiers;
-	pgpa_ri_check_iterator it;
-	pgpa_ri_checker *check;
+	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. */
-	pgpa_ri_check_start_iterate(pps->ri_check_hash, &it);
-	while ((check = pgpa_ri_check_iterate(pps->ri_check_hash, &it)) != NULL)
+	foreach_ptr(pgpa_planner_info, proot, pps->proots)
 	{
 		int			rtoffset = 0;
-		const char *rid_string;
-		Index		flat_rti;
 
 		/*
 		 * 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.
 		 */
-		if (check->key.plan_name != NULL)
+		if (proot->plan_name != NULL)
 		{
 			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
 			{
@@ -2109,18 +2082,8 @@ pgpa_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
 				 * 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
-				 * in the final rtable.
-				 *
-				 * With more complex logic, we could work around that problem
-				 * by remembering the whole contents of the subquery's rtable
-				 * during planning, determining which of those would have been
-				 * copied to the final rtable, and matching them up. But it
-				 * doesn't seem like a worthwhile endeavor for right now,
-				 * because RTIs from such subqueries won't appear in the plan
-				 * tree itself, just in the range table. Hence, we can neither
-				 * generate nor accept advice for them.
 				 */
-				if (strcmp(check->key.plan_name, rtinfo->plan_name) == 0
+				if (strcmp(proot->plan_name, rtinfo->plan_name) == 0
 					&& !rtinfo->dummy)
 				{
 					rtoffset = rtinfo->rtoffset;
@@ -2139,17 +2102,24 @@ pgpa_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
 				continue;
 		}
 
-		/*
-		 * check->key.rti is the RTI that we saw prior to range-table
-		 * flattening, so we must add the appropriate RT offset to get the
-		 * final RTI.
-		 */
-		flat_rti = check->key.rti + rtoffset;
-		Assert(flat_rti <= list_length(pstmt->rtable));
+		for (int rti = 1; rti <= proot->rid_array_size; ++rti)
+		{
+			Index		flat_rti = rtoffset + rti;
+			pgpa_identifier *rid1 = &proot->rid_array[rti - 1];
+			pgpa_identifier *rid2;
+
+			if (rid1->alias_name == NULL)
+				continue;
 
-		/* Assert that the string we compute now matches the previous one. */
-		rid_string = pgpa_identifier_string(&rt_identifiers[flat_rti - 1]);
-		Assert(strcmp(rid_string, check->rid_string) == 0);
+			Assert(flat_rti <= rtable_length);
+			rid2 = &rt_identifiers[flat_rti - 1];
+			Assert(strcmp(rid1->alias_name, rid2->alias_name) == 0);
+			Assert(rid1->occurrence == rid2->occurrence);
+			Assert(strings_equal_or_both_null(rid1->partnsp, rid2->partnsp));
+			Assert(strings_equal_or_both_null(rid1->partrel, rid2->partrel));
+			Assert(strings_equal_or_both_null(rid1->plan_name,
+											  rid2->plan_name));
+		}
 	}
 #endif
 }
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
index c70e486a7f3..e9045f69bca 100644
--- a/contrib/pg_plan_advice/pgpa_planner.h
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -12,8 +12,46 @@
 #ifndef PGPA_PLANNER_H
 #define PGPA_PLANNER_H
 
+#include "pgpa_identifier.h"
+
 extern void pgpa_planner_install_hooks(void);
 
+/*
+ * Per-PlannerInfo information that we gather during query planning.
+ */
+typedef struct pgpa_planner_info
+{
+	/* Plan name taken from the corresponding PlannerInfo; NULL at top level. */
+	char	   *plan_name;
+
+#ifdef USE_ASSERT_CHECKING
+	/* Relation identifiers computed for baserels at this query level. */
+	pgpa_identifier *rid_array;
+	int			rid_array_size;
+#endif
+
+	/*
+	 * List of Bitmapset objects. Each represents the relid set of a relation
+	 * that the planner considers making unique during semijoin planning.
+	 *
+	 * When generating advice, we should emit either SEMIJOIN_UNIQUE advice or
+	 * SEMIJOIN_NON_UNIQUE advice for each semijoin depending on whether we
+	 * chose to implement it as a semijoin or whether we instead chose to make
+	 * the nullable side unique and then perform an inner join. When the
+	 * make-unique strategy is not chosen, it's not easy to tell from the
+	 * final plan tree whether it was considered. That's awkward, because we
+	 * don't want to emit useless SEMIJOIN_NON_UNIQUE advice when there was no
+	 * decision to be made. This list lets the plan tree walker know in which
+	 * cases that approach was considered, so that it doesn't have to guess.
+	 */
+	List	   *sj_unique_rels;
+} pgpa_planner_info;
+
+/*
+ * When set to a value greater than zero, indicates that advice should be
+ * generated during query planning even in the absence of obvious reasons to
+ * do so. See pg_plan_advice_request_advice_generation().
+ */
 extern int	pgpa_planner_generate_advice;
 
 #endif
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
index 7b86cc5e6f9..6fbc784bf54 100644
--- a/contrib/pg_plan_advice/pgpa_walker.c
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -12,6 +12,7 @@
 #include "postgres.h"
 
 #include "pgpa_join.h"
+#include "pgpa_planner.h"
 #include "pgpa_scan.h"
 #include "pgpa_walker.h"
 
@@ -64,12 +65,12 @@ static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
  *
  * Populates walker based on a traversal of the Plan trees in pstmt.
  *
- * sj_unique_rels is a list of pgpa_sj_unique_rel objects, one for each
- * relation we considered making unique as part of semijoin planning.
+ * proots is the list of pgpa_planner_info objects that were generated
+ * during planning.
  */
 void
 pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
-				 List *sj_unique_rels)
+				 List *proots)
 {
 	ListCell   *lc;
 	List	   *sj_unique_rtis = NULL;
@@ -92,19 +93,21 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
 	}
 
 	/* Adjust RTIs from sj_unique_rels for the flattened range table. */
-	foreach_ptr(pgpa_sj_unique_rel, ur, sj_unique_rels)
+	foreach_ptr(pgpa_planner_info, proot, proots)
 	{
-		int			rtindex = -1;
 		int			rtoffset = 0;
 		bool		dummy = false;
-		Bitmapset  *relids = NULL;
+
+		/* 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 (ur->plan_name != NULL)
+		if (proot->plan_name != NULL)
 		{
 			foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
 			{
-				if (strcmp(ur->plan_name, rtinfo->plan_name) == 0)
+				if (strcmp(proot->plan_name, rtinfo->plan_name) == 0)
 				{
 					rtoffset = rtinfo->rtoffset;
 					dummy = rtinfo->dummy;
@@ -113,19 +116,24 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
 			}
 
 			if (rtoffset == 0)
-				elog(ERROR, "no rtoffset for plan %s", ur->plan_name);
+				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 entry from the original set. */
-		while ((rtindex = bms_next_member(ur->relids, rtindex)) >= 0)
-			relids = bms_add_member(relids, rtindex + rtoffset);
+		/* Offset each relid set by the rtoffset we just computed. */
+		foreach_node(Bitmapset, relids, proot->sj_unique_rels)
+		{
+			int			rtindex = -1;
+			Bitmapset  *flat_relids = NULL;
 
-		/* Store the resulting set. */
-		sj_unique_rtis = lappend(sj_unique_rtis, relids);
+			while ((rtindex = bms_next_member(relids, rtindex)) >= 0)
+				flat_relids = bms_add_member(flat_relids, rtindex + rtoffset);
+
+			sj_unique_rtis = lappend(sj_unique_rtis, flat_relids);
+		}
 	}
 
 	/*
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
index 4890d554dd3..9b74cd3ba55 100644
--- a/contrib/pg_plan_advice/pgpa_walker.h
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -16,24 +16,6 @@
 #include "pgpa_join.h"
 #include "pgpa_scan.h"
 
-/*
- * When generating advice, we should emit either SEMIJOIN_UNIQUE advice or
- * SEMIJOIN_NON_UNIQUE advice for each semijoin depending on whether we chose
- * to implement it as a semijoin or whether we instead chose to make the
- * nullable side unique and then perform an inner join. When the make-unique
- * strategy is not chosen, it's not easy to tell from the final plan tree
- * whether it was considered. That's awkward, because we don't want to emit
- * useless SEMIJOIN_NON_UNIQUE advice when there was no decision to be made.
- *
- * To avoid that, during planning, we create a pgpa_sj_unique_rel for each
- * relation that we considered making unique for purposes of semijoin planning.
- */
-typedef struct pgpa_sj_unique_rel
-{
-	char	   *plan_name;
-	Bitmapset  *relids;
-} pgpa_sj_unique_rel;
-
 /*
  * We use the term "query feature" to refer to plan nodes that are interesting
  * in the following way: to generate advice, we'll need to know the set of
@@ -122,7 +104,7 @@ typedef struct pgpa_plan_walker_context
 
 extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,
 							 PlannedStmt *pstmt,
-							 List *sj_unique_rels);
+							 List *proots);
 
 extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
 									pgpa_qf_type type,
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 8df23840e57..12f4b8a7bf8 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4034,14 +4034,12 @@ pgpa_join_strategy
 pgpa_join_unroller
 pgpa_output_context
 pgpa_plan_walker_context
+pgpa_planner_info
 pgpa_planner_state
 pgpa_qf_type
 pgpa_query_feature
-pgpa_ri_checker
-pgpa_ri_checker_key
 pgpa_scan
 pgpa_scan_strategy
-pgpa_sj_unique_rel
 pgpa_target_type
 pgpa_trove
 pgpa_trove_entry
-- 
2.51.0



  [application/octet-stream] v22-0004-pg_plan_advice-Invent-DO_NOT_SCAN-relation_ident.patch (43.4K, 4-v22-0004-pg_plan_advice-Invent-DO_NOT_SCAN-relation_ident.patch)
  download | inline diff:
From 0e58860cf50fdd08712bfd99326686c89a6f3312 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 24 Mar 2026 08:40:54 -0400
Subject: [PATCH v22 4/6] 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         | 216 ++++++++++--------
 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, 584 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 70139ff42be..751a255615a 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. */
@@ -1622,6 +1617,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;
@@ -1632,16 +1629,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
@@ -1675,9 +1674,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 &&
@@ -1712,7 +1711,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;
@@ -1791,7 +1790,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,
@@ -1835,14 +1834,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;
@@ -1998,9 +1991,41 @@ 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;
+	if (root->alternative_root == NULL)
+		new_proot->alternative_plan_name = root->plan_name;
+	else
+		new_proot->alternative_plan_name = root->alternative_root->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;
 
@@ -2008,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 +2064,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_state 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 +2098,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



  [application/octet-stream] v22-0001-Respect-disabled_nodes-in-fix_alternative_subpla.patch (3.4K, 5-v22-0001-Respect-disabled_nodes-in-fix_alternative_subpla.patch)
  download | inline diff:
From e892484828a09ce6ca6f8943c7b3e650a53050ce Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 20 Mar 2026 14:04:41 -0400
Subject: [PATCH v22 1/6] Respect disabled_nodes in fix_alternative_subplan.

When my commit 12444183e40187a9fb6002a3912053f302725f0a added the
concept of disabled_nodes, it failed to add a disabled_nodes field
to SubPlan. This is a regression: before that commit, when
fix_alternative_subplan compared the costs of two plans, the number
of disabled nodes affected the result, because it was just a
component of the total cost. After that commit, it no longer did,
making it possible for a disabled path to win on cost over one that
is not disabled. Fix that.
---
 src/backend/optimizer/path/costsize.c |  1 +
 src/backend/optimizer/plan/setrefs.c  | 14 ++++++++++----
 src/include/nodes/primnodes.h         |  1 +
 3 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 56d45287c89..1c575e56ff6 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -4761,6 +4761,7 @@ cost_subplan(PlannerInfo *root, SubPlan *subplan, Plan *plan)
 			sp_cost.per_tuple += plan->startup_cost;
 	}
 
+	subplan->disabled_nodes = plan->disabled_nodes;
 	subplan->startup_cost = sp_cost.startup;
 	subplan->per_call_cost = sp_cost.per_tuple;
 }
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 1b5b9b5ed9c..ff0e875f2a2 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -2234,9 +2234,12 @@ fix_alternative_subplan(PlannerInfo *root, AlternativeSubPlan *asplan,
 
 	/*
 	 * Compute the estimated cost of each subplan assuming num_exec
-	 * executions, and keep the cheapest one.  In event of exact equality of
-	 * estimates, we prefer the later plan; this is a bit arbitrary, but in
-	 * current usage it biases us to break ties against fast-start subplans.
+	 * executions, and keep the cheapest one.  If one subplan has more
+	 * disabled nodes than another, choose the one with fewer disabled nodes
+	 * regardless of cost; this parallels compare_path_costs.  In event of
+	 * exact equality of estimates, we prefer the later plan; this is a bit
+	 * arbitrary, but in current usage it biases us to break ties against
+	 * fast-start subplans.
 	 */
 	Assert(asplan->subplans != NIL);
 
@@ -2246,7 +2249,10 @@ fix_alternative_subplan(PlannerInfo *root, AlternativeSubPlan *asplan,
 		Cost		curcost;
 
 		curcost = curplan->startup_cost + num_exec * curplan->per_call_cost;
-		if (bestplan == NULL || curcost <= bestcost)
+		if (bestplan == NULL ||
+			curplan->disabled_nodes < bestplan->disabled_nodes ||
+			(curplan->disabled_nodes == bestplan->disabled_nodes &&
+			 curcost <= bestcost))
 		{
 			bestplan = curplan;
 			bestcost = curcost;
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index b67e56e6c5a..f5b6b45664a 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -1124,6 +1124,7 @@ typedef struct SubPlan
 	List	   *parParam;		/* indices of input Params from parent plan */
 	List	   *args;			/* exprs to pass as parParam values */
 	/* Estimated execution costs: */
+	int			disabled_nodes; /* count of disabled nodes in the plan */
 	Cost		startup_cost;	/* one-time setup cost */
 	Cost		per_call_cost;	/* cost for each subplan evaluation */
 } SubPlan;
-- 
2.51.0



  [application/octet-stream] v22-0005-Add-pg_collect_advice-contrib-module.patch (56.5K, 6-v22-0005-Add-pg_collect_advice-contrib-module.patch)
  download | inline diff:
From bdee22713c7956fc09af3083b05c26954ae6a97f Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Thu, 26 Feb 2026 16:51:16 -0500
Subject: [PATCH v22 5/6] Add pg_collect_advice contrib module.

This module allows for bulk collection of queries and the associated
plan advice strings using either backend-local memory or dynamic
shared memory. In either case, memory usage can be limited by
restriction the maximum number of queries and advice strings stored.
Care should be taken with these values, and with the use of this
module in general, because it's easy to chew up an unreasonably large
amount of memory. Unlike pg_stat_statements, this module does not
provide for query normalization or even deduplication; it simply makes
a record for every query planned.

It can be useful to enable query ID computaton before using the
module, but it's not required. If not done, all queries will simply
show a query ID of zero.

Reviewed-by: Alexandra Wang <[email protected]>
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_collect_advice/Makefile            |  26 +
 contrib/pg_collect_advice/collector.c         | 649 ++++++++++++++++++
 .../expected/local_collector.out              |  69 ++
 contrib/pg_collect_advice/interface.c         | 303 ++++++++
 contrib/pg_collect_advice/meson.build         |  41 ++
 .../pg_collect_advice--1.0.sql                |  43 ++
 .../pg_collect_advice.control                 |   5 +
 contrib/pg_collect_advice/pg_collect_advice.h |  39 ++
 .../pg_collect_advice/sql/local_collector.sql |  46 ++
 .../t/001_shared_collector.pl                 | 154 +++++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgcollectadvice.sgml             | 244 +++++++
 src/tools/pgindent/typedefs.list              |   6 +
 16 files changed, 1629 insertions(+)
 create mode 100644 contrib/pg_collect_advice/Makefile
 create mode 100644 contrib/pg_collect_advice/collector.c
 create mode 100644 contrib/pg_collect_advice/expected/local_collector.out
 create mode 100644 contrib/pg_collect_advice/interface.c
 create mode 100644 contrib/pg_collect_advice/meson.build
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice--1.0.sql
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.control
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.h
 create mode 100644 contrib/pg_collect_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_collect_advice/t/001_shared_collector.pl
 create mode 100644 doc/src/sgml/pgcollectadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index dd04c20acd2..22071034e51 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -31,6 +31,7 @@ SUBDIRS = \
 		pageinspect	\
 		passwordcheck	\
 		pg_buffercache	\
+		pg_collect_advice \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
diff --git a/contrib/meson.build b/contrib/meson.build
index 5a752eac347..ff422d9b7fc 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -45,6 +45,7 @@ subdir('pageinspect')
 subdir('passwordcheck')
 subdir('pg_buffercache')
 subdir('pgcrypto')
+subdir('pg_collect_advice')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
diff --git a/contrib/pg_collect_advice/Makefile b/contrib/pg_collect_advice/Makefile
new file mode 100644
index 00000000000..594c1bf82b2
--- /dev/null
+++ b/contrib/pg_collect_advice/Makefile
@@ -0,0 +1,26 @@
+# contrib/pg_collect_advice/Makefile
+
+MODULE_big = pg_collect_advice
+OBJS = \
+	$(WIN32RES) \
+	collector.o \
+	interface.o
+
+EXTENSION = pg_collect_advice
+DATA = pg_collect_advice--1.0.sql
+PGFILEDESC = "pg_collect_advice - collect queries and their plan advice strings"
+
+REGRESS = local_collector
+EXTRA_INSTALL = contrib/pg_plan_advice
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_collect_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_collect_advice/collector.c b/contrib/pg_collect_advice/collector.c
new file mode 100644
index 00000000000..d9fc3238fbd
--- /dev/null
+++ b/contrib/pg_collect_advice/collector.c
@@ -0,0 +1,649 @@
+/*-------------------------------------------------------------------------
+ *
+ * collector.c
+ *	  workhorse for saving plan advice in backend-local or shared memory
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+#include "utils/tuplestore.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgca_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgca_collected_advice;
+
+/*
+ * A bunch of pointers to pgca_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgca_local_advice_chunk
+{
+	pgca_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgca_local_advice_chunk;
+
+/*
+ * Information about all of the pgca_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgca_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgca_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgca_local_advice_chunk **chunks;
+} pgca_local_advice;
+
+/*
+ * Just like pgca_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgca_shared_advice_chunk;
+
+/*
+ * Just like pgca_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgca_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgca_local_advice *local_collector = NULL;
+static pgca_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgca_collected_advice *make_collected_advice(Oid userid,
+													Oid dbid,
+													uint64 queryId,
+													TimestampTz timestamp,
+													const char *query_string,
+													const char *advice_string,
+													dsa_area *area,
+													dsa_pointer *result);
+static void store_local_advice(pgca_collected_advice *ca);
+static void trim_local_advice(int limit);
+static void store_shared_advice(dsa_pointer ca_pointer);
+static void trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgca_collected_advice */
+static inline const char *
+query_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgca_collected_advice */
+static inline const char *
+advice_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pg_collect_advice_save(uint64 queryId, const char *query_string,
+					   const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_collect_advice_local_collector &&
+		pg_collect_advice_local_collection_limit > 0)
+	{
+		pgca_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+		ca = make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string,
+								   NULL, NULL);
+		store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_collect_advice_shared_collector &&
+		pg_collect_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_collect_advice_dsa_area();
+		dsa_pointer ca_pointer = InvalidDsaPointer; /* placate compiler */
+
+		make_collected_advice(userid, dbid, queryId, now,
+							  query_string, advice_string, area,
+							  &ca_pointer);
+		store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgca_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgca_collected_advice *
+make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+					  TimestampTz timestamp,
+					  const char *query_string,
+					  const char *advice_string,
+					  dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgca_collected_advice *ca;
+
+	total_length = offsetof(pgca_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = userid;
+	ca->dbid = dbid;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pgca_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+store_local_advice(pgca_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgca_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgca_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgca_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgca_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_local_advice(pg_collect_advice_local_collection_limit);
+}
+
+/*
+ * Add a pgca_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_collect_advice DSA area
+ * and should point to an object of type pgca_collected_advice.
+ */
+static void
+store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	pgca_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgca_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgca_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgca_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_shared_advice(area, pg_collect_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+trim_local_advice(int limit)
+{
+	pgca_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgca_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgca_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+trim_shared_advice(dsa_area *area, int limit)
+{
+	pgca_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(dsa_pointer) * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in shared memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgca_shared_advice *sa = shared_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* If there's no chunk array yet, there's nothing to do. */
+	if (sa->chunks == InvalidDsaPointer)
+	{
+		LWLockRelease(&state->lock);
+		return (Datum) 0;
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_collect_advice/expected/local_collector.out b/contrib/pg_collect_advice/expected/local_collector.out
new file mode 100644
index 00000000000..f57b96ee835
--- /dev/null
+++ b/contrib/pg_collect_advice/expected/local_collector.out
@@ -0,0 +1,69 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_collect_advice/interface.c b/contrib/pg_collect_advice/interface.c
new file mode 100644
index 00000000000..feb11974152
--- /dev/null
+++ b/contrib/pg_collect_advice/interface.c
@@ -0,0 +1,303 @@
+/*-------------------------------------------------------------------------
+ *
+ * interface.c
+ *	  interface routines for the plan advice collector
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/interface.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+/* Shared memory pointers */
+static pgca_shared_state *pgca_state = NULL;
+static dsa_area *pgca_dsa_area = NULL;
+
+/* GUC variables */
+bool		pg_collect_advice_local_collector = false;
+int			pg_collect_advice_local_collection_limit = 0;
+bool		pg_collect_advice_shared_collector = false;
+int			pg_collect_advice_shared_collection_limit = 0;
+
+/* Shadow variables for GUC assign hooks */
+static bool pg_collect_advice_local_collector_as_assigned = false;
+static bool pg_collect_advice_shared_collector_as_assigned = false;
+
+/* Other file-level globals */
+static void (*request_advice_generation_fn) (bool activate) = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+static MemoryContext pgca_memory_context = NULL;
+
+/* Function prototypes */
+static void pgca_init_shared_state(void *ptr, void *arg);
+static void pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string,
+								  PlannedStmt *pstmt);
+static void pg_collect_advice_local_collector_assign_hook(bool newval,
+														  void *extra);
+static void pg_collect_advice_shared_collector_assign_hook(bool newval,
+														   void *extra);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	/*
+	 * Get a pointer so we can call pg_plan_advice_request_advice_generation.
+	 *
+	 * We need to do this before defining custom GUCs; otherwise, our assign
+	 * hook will try to use this function pointer before it's initialized.
+	 *
+	 * We also need to do this before installing our own hooks, so that if
+	 * pg_plan_advice is not yet loaded, it will install its hooks before we
+	 * install ours. (See comments in pgca_planner_shutdown.)
+	 */
+	request_advice_generation_fn =
+		load_external_function("pg_plan_advice",
+							   "pg_plan_advice_request_advice_generation",
+							   true, NULL);
+
+	/* Define our GUCs. */
+	DefineCustomBoolVariable("pg_collect_advice.local_collector",
+							 "Enable the local advice collector.",
+							 NULL,
+							 &pg_collect_advice_local_collector,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_local_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_collect_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomBoolVariable("pg_collect_advice.shared_collector",
+							 "Enable the shared advice collector.",
+							 NULL,
+							 &pg_collect_advice_shared_collector,
+							 false,
+							 PGC_SUSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_shared_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_collect_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_collect_advice");
+
+	/* Install hooks */
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgca_planner_shutdown;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgca_init_shared_state(void *ptr, void *arg)
+{
+	pgca_shared_state *state = (pgca_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_collect_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_collect_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_collect_advice_get_mcxt(void)
+{
+	if (pgca_memory_context == NULL)
+		pgca_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_collect_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgca_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ */
+pgca_shared_state *
+pg_collect_advice_attach(void)
+{
+	if (pgca_state == NULL)
+	{
+		bool		found;
+
+		pgca_state =
+			GetNamedDSMSegment("pg_collect_advice", sizeof(pgca_shared_state),
+							   pgca_init_shared_state, &found, NULL);
+	}
+
+	return pgca_state;
+}
+
+/*
+ * Return a pointer to pg_collect_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_collect_advice_dsa_area(void)
+{
+	if (pgca_dsa_area == NULL)
+	{
+		pgca_shared_state *state = pg_collect_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgca_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgca_dsa_area);
+			state->area = dsa_get_handle(pgca_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgca_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgca_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgca_dsa_area;
+}
+
+/*
+ * After planning is complete, retrieve the advice string, if present, and
+ * pass it through to the collector.
+ */
+static void
+pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	DefElem    *pgpa_item;
+	DefElem    *advice_string_item;
+	char	   *advice_string;
+
+	/*
+	 * Pass call to previous hook.
+	 *
+	 * We want to be called after pg_plan_advice's shutdown hook has already
+	 * executed. Our _PG_init() makes sure that pg_plan_advice's hooks are
+	 * always loaded before ours, and here we pass the hook call down first,
+	 * before doing our own work. The combination of those two things should
+	 * be good enough to ensure that the advice string is already present when
+	 * we go looking for it.
+	 */
+	if (prev_planner_shutdown)
+		(*prev_planner_shutdown) (glob, parse, query_string, pstmt);
+
+	/* Fish out the advice string. If not found, do nothing. */
+	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
+										"pg_plan_advice");
+	if (pgpa_item == NULL)
+		return;
+	advice_string_item = find_defelem_by_defname((List *) pgpa_item->arg,
+												 "advice_string");
+	if (advice_string_item == NULL)
+		return;
+	advice_string = strVal(advice_string_item->arg);
+
+	/*
+	 * Pass it through to the actual collector. But, if it's the empty string,
+	 * we assume that collecting it is uninteresting.
+	 */
+	if (advice_string[0] != '\0')
+		pg_collect_advice_save(pstmt->queryId, query_string, advice_string);
+}
+
+/*
+ * pgca_planner_shutdown won't find any advice to collect unless we've
+ * requested that it be generated. So, whenever the effective value of
+ * pg_collect_advice.local_collector changes, either make or
+ * revoke a request for advice generation.
+ */
+static void
+pg_collect_advice_local_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_local_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_local_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_local_collector_as_assigned = newval;
+}
+
+/*
+ * Same as above, but for pg_collect_advice.shared_collector
+ */
+static void
+pg_collect_advice_shared_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_shared_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_shared_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_shared_collector_as_assigned = newval;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_collect_advice/meson.build b/contrib/pg_collect_advice/meson.build
new file mode 100644
index 00000000000..102dc65d260
--- /dev/null
+++ b/contrib/pg_collect_advice/meson.build
@@ -0,0 +1,41 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_collect_advice_sources = files(
+  'collector.c',
+  'interface.c',
+)
+
+if host_system == 'windows'
+  pg_collect_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_collect_advice',
+    '--FILEDESC', 'pg_collect_advice - collect queries and their plan advice strings',])
+endif
+
+pg_collect_advice = shared_module('pg_collect_advice',
+  pg_collect_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_collect_advice
+
+install_data(
+  'pg_collect_advice--1.0.sql',
+  'pg_collect_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_collect_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'local_collector',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_shared_collector.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_collect_advice/pg_collect_advice--1.0.sql b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
new file mode 100644
index 00000000000..0be86c54fc1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_collect_advice/pg_collect_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_collect_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_clear_collected_shared_advice() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_collect_advice/pg_collect_advice.control b/contrib/pg_collect_advice/pg_collect_advice.control
new file mode 100644
index 00000000000..601e5e24ea1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.control
@@ -0,0 +1,5 @@
+# pg_collect_advice extension
+comment = 'collect queries and the associated plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_collect_advice'
+relocatable = true
diff --git a/contrib/pg_collect_advice/pg_collect_advice.h b/contrib/pg_collect_advice/pg_collect_advice.h
new file mode 100644
index 00000000000..480c2c633c4
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.h
@@ -0,0 +1,39 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_collect_advice.h
+ *	  definitions and declarations for pg_collect_advice module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/pg_collect_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_COLLECT_ADVICE_H
+#define PG_COLLECT_ADVICE_H
+
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgca_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgca_shared_state;
+
+/* GUC variables */
+extern bool pg_collect_advice_local_collector;
+extern int	pg_collect_advice_local_collection_limit;
+extern bool pg_collect_advice_shared_collector;
+extern int	pg_collect_advice_shared_collection_limit;
+
+/* Function prototypes */
+extern MemoryContext pg_collect_advice_get_mcxt(void);
+extern pgca_shared_state *pg_collect_advice_attach(void);
+extern dsa_area *pg_collect_advice_dsa_area(void);
+extern void pg_collect_advice_save(uint64 queryId, const char *query_string,
+								   const char *advice_string);
+
+#endif
diff --git a/contrib/pg_collect_advice/sql/local_collector.sql b/contrib/pg_collect_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..41b187c5375
--- /dev/null
+++ b/contrib/pg_collect_advice/sql/local_collector.sql
@@ -0,0 +1,46 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_collect_advice/t/001_shared_collector.pl b/contrib/pg_collect_advice/t/001_shared_collector.pl
new file mode 100644
index 00000000000..bba0c883e5a
--- /dev/null
+++ b/contrib/pg_collect_advice/t/001_shared_collector.pl
@@ -0,0 +1,154 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Test the shared advice collector.
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Helper function, to avoid depending on exact line-break behavior.
+sub smash_whitespace
+{
+	my $s = shift;
+	$s =~ s/^\s+//;
+	$s =~ s/\s+$//;
+	$s =~ s/\s+/ /g;
+	return $s;
+}
+
+# Retrieve all collected shared advice as an array of whitespace-normalized
+# strings, ordered by id.
+sub get_collected_shared_advice
+{
+	my $psql = shift;
+	my $output = $psql->query_safe(
+		"SELECT string_agg(advice, '!SEPARATOR!' ORDER BY id) "
+		. "FROM pg_get_collected_shared_advice()");
+	return () if $output eq '';
+	return map { smash_whitespace($_) } split(/!SEPARATOR!/, $output);
+}
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Load pg_collect_advice and configure a shared collection limit of 5.
+$node->append_conf('postgresql.conf', <<EOM);
+shared_preload_libraries=pg_collect_advice
+pg_collect_advice.shared_collection_limit=5
+EOM
+$node->start;
+
+# Create the extension so we can access the collector
+my $test_db = 'collection_test';
+my $test_role = 'collection_role';
+$node->safe_psql('postgres', <<EOM);
+CREATE DATABASE $test_db;
+CREATE USER $test_role;
+ALTER ROLE $test_role SET pg_collect_advice.shared_collector = on;
+EOM
+$node->safe_psql($test_db, 'CREATE EXTENSION pg_collect_advice');
+
+# Set up two connections, one to control the testing process, and the other
+# to execute the queries under test.
+my $psql_control = $node->background_psql($test_db, on_error_stop => 1);
+my $psql_test =
+	$node->background_psql($test_db, on_error_stop => 1,
+						   extra_params => [ '--username' => $test_role ]);
+
+# Initial setup.
+$psql_control->query_safe(<<EOM);
+GRANT CREATE ON SCHEMA public TO $test_role;
+GRANT SET ON PARAMETER pg_collect_advice.shared_collection_limit TO $test_role;
+SET ROLE $test_role;
+CREATE TABLE sac_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO sac_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE sac_dim;
+
+CREATE TABLE sac_fact (
+	id int primary key,
+	dim_id integer not null references sac_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO sac_fact
+SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX sac_fact_dim_id ON sac_fact (dim_id);
+VACUUM ANALYZE sac_fact;
+RESET ROLE;
+EOM
+
+# Run a few test queries.
+$psql_test->query_safe(<<'EOM');
+SELECT * FROM sac_fact WHERE id = 42;
+SELECT * FROM sac_dim d JOIN sac_fact f ON d.id = f.dim_id;
+SELECT * FROM sac_dim d
+    WHERE d.id IN (SELECT f.dim_id FROM sac_fact f);
+EOM
+
+# Check that we got three advice collections, and the right values for each.
+my @advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 3, "three advice entries collected");
+is($advice[0], 'INDEX_SCAN(sac_fact public.sac_fact_pkey) NO_GATHER(sac_fact)',
+	"correct advice for query 1");
+is($advice[1], 'JOIN_ORDER(f d) HASH_JOIN(d) SEQ_SCAN(f d) NO_GATHER(d f)',
+	"correct advice for query 2");
+is($advice[2], 'JOIN_ORDER(d f) NESTED_LOOP_PLAIN(f) SEQ_SCAN(d) INDEX_ONLY_SCAN(f public.sac_fact_dim_id) SEMIJOIN_NON_UNIQUE(f) NO_GATHER(d f)',
+	"correct advice for query 3");
+
+# Run a few more test queries, overrunning the limit. (SET and PREPARE don't
+# trigger planning, but EXECUTE does.)
+$psql_test->query_safe(<<'EOM');
+BEGIN;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+SELECT count(*) FROM sac_fact;
+COMMIT;
+EXPLAIN SELECT * FROM sac_dim;
+PREPARE test_stmt AS SELECT * FROM sac_fact WHERE id = $1;
+EXECUTE test_stmt(42);
+EOM
+
+# Check that advice collection was trimmed to the configured limit.
+@advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 5, "advice trimmed to collection limit");
+
+# Check the advice for queries 4, 5, and 6.
+is($advice[2], 'SEQ_SCAN(sac_fact) GATHER(sac_fact)',
+	"correct advice for query 4");
+is($advice[3], 'SEQ_SCAN(sac_dim) NO_GATHER(sac_dim)',
+	"correct advice for query 5");
+is($advice[4],
+	'INDEX_SCAN(sac_fact public.sac_fact_pkey) NO_GATHER(sac_fact)',
+	"correct advice for query 6");
+
+# Raise the collection limit so that we can collect enough advice to need
+# multiple chunks, and then revert back to the old value, so that we try
+# to free an entire chunk.
+$psql_test->query_safe("SET pg_collect_advice.shared_collection_limit = 1500");
+$psql_test->query_safe(<<'EOM');
+DO $$
+BEGIN
+	FOR i IN 1..1500 LOOP
+		EXECUTE 'SELECT 1';
+	END LOOP;
+END $$;
+EOM
+@advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 1500, "increased collection limit reached");
+$psql_test->query_safe("RESET pg_collect_advice.shared_collection_limit");
+$psql_test->query_safe("SELECT * FROM sac_dim");
+@advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 5, "advice trimmed across chunk boundary");
+
+# Try clearing all the advice.
+$psql_control->query_safe("SELECT pg_clear_collected_shared_advice()");
+@advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 0, "all shared advice cleared");
+
+# Clean up.
+$psql_test->quit;
+$psql_control->quit;
+done_testing();
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index bdd4865f53f..2ab6fafbab1 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -152,6 +152,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pageinspect;
  &passwordcheck;
  &pgbuffercache;
+ &pgcollectadvice;
  &pgcrypto;
  &pgfreespacemap;
  &pglogicalinspect;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index d90b4338d2a..407ff3abffe 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -145,6 +145,7 @@
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
+<!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
 <!ENTITY pglogicalinspect  SYSTEM "pglogicalinspect.sgml">
diff --git a/doc/src/sgml/pgcollectadvice.sgml b/doc/src/sgml/pgcollectadvice.sgml
new file mode 100644
index 00000000000..220aabe78c6
--- /dev/null
+++ b/doc/src/sgml/pgcollectadvice.sgml
@@ -0,0 +1,244 @@
+<!-- doc/src/sgml/pgcollectadvice.sgml -->
+
+<sect1 id="pgcollectadvice" xreflabel="pg_collect_advice">
+ <title>pg_collect_advice &mdash; collect queries and their plan advice strings</title>
+
+ <indexterm zone="pgcollectadvice">
+  <primary>pg_collect_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_collect_advice</filename> extension allows you to
+  automatically generate plan advice each time a query is planned and store
+  the query and the generated advice string either in local or shared memory.
+  Note that this extension requires the <xref linkend="pgplanadvice" /> module,
+  which performs the actual plan advice generation; this module only knows
+  how to store the generated advice for later examination. Whenever
+  <literal>pg_collect_advice</literal> is loaded, it will automatically load
+  <literal>pg_plan_advice</literal>.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_collect_advice</literal> in at least
+  one database, so that you have a way to examine the collected advice.
+  You will also need the <literal>pg_collect_advice</literal> module
+  to be loaded in all sessions where advice is to be collected. It will
+  usually be best to do this by adding <literal>pg_collect_advice</literal>
+  to <xref linkend="guc-shared-preload-libraries"/> and restarting the
+  server.
+ </para>
+
+ <para>
+  <literal>pg_collect_advice</literal> includes both a shared advice
+  collector and a local advice collector. The local advice collector makes
+  queries and their advice strings visible only to the session where those
+  queries were planned, while the shared advice collector collects data
+  on a system-wide basis, and authorized users can examine data from all
+  sessions.
+ </para>
+
+ <para>
+  To enable a collector, you must first set a collection limit. When the
+  number of queries for which advice has been stored exceeds the collection
+  limit, the oldest queries and the corresponding advice will be discarded.
+  Then, you must adjust a separate setting to actually enable advice
+  collection. For the local collector, set the collection limit by configuring
+  <literal>pg_collect_advice.local_collection_limit</literal> to a value
+  greater than zero, and then enable advice collection by setting
+  <literal>pg_collect_advice.local_collector = true</literal>. For the shared
+  collector, the procedure is the same, except that the names of the settings
+  are <literal>pg_collect_advice.shared_collection_limit</literal> and
+  <literal>pg_collect_advice.shared_collector</literal>. Note that in both
+  cases, query texts and advice strings are stored in memory, so
+  configuring large limits may result in considerable memory consumption.
+ </para>
+
+ <para>
+  Once the collector is enabled, you can run any queries for which you wish
+  to see the generated plan advice. Then, you can examine what has been
+  collected using whichever of
+  <literal>SELECT * FROM pg_get_collected_local_advice()</literal> or
+  <literal>SELECT * FROM pg_get_collected_shared_advice()</literal>
+  corresponds to the collector you enabled. To discard the collected advice
+  and release memory, you can call
+  <literal>pg_clear_collected_local_advice()</literal>
+  or <literal>pg_clear_collected_shared_advice()</literal>.
+ </para>
+
+ <para>
+  In addition to the query texts and advice strings, the advice collectors
+  will also store the OID of the role that caused the query to be planned,
+  the OID of the database in which the query was planned, the query ID,
+  and the time at which the collection occurred. This module does not
+  automatically enable query ID computation; therefore, if you want the
+  query ID value to be populated in collected advice, be sure to configure
+  <literal>compute_query_id = on</literal>. Otherwise, the query ID may
+  always show as <literal>0</literal>.
+ </para>
+
+ <sect2 id="pgcollectadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_local_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from backend-local
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_local_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the local
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_shared_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from shared
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_shared_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the shared
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collector</varname> enables the
+      local advice collector. The default value is <literal>false</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      local advice collector. The default value is <literal>0</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collector</varname> enables the
+      shared advice collector. The default value is <literal>false</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      shared advice collector. The default value is <literal>0</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 12f4b8a7bf8..7349c06a067 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4019,6 +4019,12 @@ pg_uuid_t
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgca_collected_advice
+pgca_local_advice
+pgca_local_advice_chunk
+pgca_shared_advice
+pgca_shared_advice_chunk
+pgca_shared_state
 pgpa_advice_item
 pgpa_advice_tag_type
 pgpa_advice_target
-- 
2.51.0



  [application/octet-stream] v22-0006-Add-pg_stash_advice-contrib-module.patch (58.7K, 7-v22-0006-Add-pg_stash_advice-contrib-module.patch)
  download | inline diff:
From 8230c1b08dedb519e67d626d74cbab60f314bdc8 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 27 Feb 2026 16:58:14 -0500
Subject: [PATCH v22 6/6] Add pg_stash_advice contrib module.

This module allows plan advice strings to be provided automatically
from an in-memory advice stash. Advice stashes are stored in dynamic
shared memory and must be recreated and repopulated after a server
restart. If pg_stash_advice.stash_name is set to the name of an advice
stash, and if query identifiers are enabled, the query identifier
for each query will be looked up in the advice stash and the
associated advice string, if any, will be used each time that query
is planned.
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_stash_advice/Makefile              |  26 +
 .../expected/pg_stash_advice.out              | 328 +++++++
 contrib/pg_stash_advice/meson.build           |  35 +
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |  43 +
 contrib/pg_stash_advice/pg_stash_advice.c     | 900 ++++++++++++++++++
 .../pg_stash_advice/pg_stash_advice.control   |   5 +
 .../pg_stash_advice/sql/pg_stash_advice.sql   | 147 +++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgstashadvice.sgml               | 218 +++++
 src/tools/pgindent/typedefs.list              |   6 +
 13 files changed, 1712 insertions(+)
 create mode 100644 contrib/pg_stash_advice/Makefile
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice.out
 create mode 100644 contrib/pg_stash_advice/meson.build
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice--1.0.sql
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.c
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.control
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice.sql
 create mode 100644 doc/src/sgml/pgstashadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 22071034e51..06615e123f0 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -37,6 +37,7 @@ SUBDIRS = \
 		pg_overexplain \
 		pg_plan_advice \
 		pg_prewarm	\
+		pg_stash_advice	\
 		pg_stat_statements \
 		pg_surgery	\
 		pg_trgm		\
diff --git a/contrib/meson.build b/contrib/meson.build
index ff422d9b7fc..4862ba97ed1 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -52,6 +52,7 @@ subdir('pg_overexplain')
 subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
+subdir('pg_stash_advice')
 subdir('pg_stat_statements')
 subdir('pgstattuple')
 subdir('pg_surgery')
diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
new file mode 100644
index 00000000000..cd9b7f30115
--- /dev/null
+++ b/contrib/pg_stash_advice/Makefile
@@ -0,0 +1,26 @@
+# contrib/pg_stash_advice/Makefile
+
+MODULE_big = pg_stash_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_stash_advice.o
+
+EXTENSION = pg_stash_advice
+DATA = pg_stash_advice--1.0.sql
+PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
+
+REGRESS = pg_stash_advice
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+ifdef USE_PGXS
+PG_CPPFLAGS = -I$(includedir_server)/extension
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice
+subdir = contrib/pg_stash_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
new file mode 100644
index 00000000000..d1e93579d8a
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -0,0 +1,328 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
+(13 rows)
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+(13 rows)
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           2
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+ stash_name | advice_string 
+------------+---------------
+(0 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+ERROR:  advice stash "no_such_stash" does not exist
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           1
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   | advice_string 
+---------------+---------------
+ regress_stash | SEQ_SCAN(d1)
+(1 row)
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+ERROR:  advice stash "regress_stash" already exists
+SELECT pg_drop_advice_stash('no_such_stash');
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+ERROR:  advice stash "no_such_stash" does not exist
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+ERROR:  cannot set advice string for query ID 0
+-- Stash names must be non-empty, ASCII, and not too long.
+SELECT pg_create_advice_stash('');
+ERROR:  advice stash name may not be zero length
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+ERROR:  advice stash names may not be longer than 63 bytes
+SELECT pg_create_advice_stash(E'caf\u00e9');
+ERROR:  advice stash name must not contain non-ASCII characters
+SET pg_stash_advice.stash_name = 'café';
+ERROR:  invalid value for parameter "pg_stash_advice.stash_name": "café"
+DETAIL:  advice stash name must not contain non-ASCII characters
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
+SELECT pg_drop_advice_stash('regress_empty_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
new file mode 100644
index 00000000000..b666bcd0f1b
--- /dev/null
+++ b/contrib/pg_stash_advice/meson.build
@@ -0,0 +1,35 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_stash_advice_sources = files(
+  'pg_stash_advice.c'
+)
+
+if host_system == 'windows'
+  pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_stash_advice',
+    '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',])
+endif
+
+pg_stash_advice = shared_module('pg_stash_advice',
+  pg_stash_advice_sources,
+  include_directories: [pg_plan_advice_inc, include_directories('.')],
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_stash_advice
+
+install_data(
+  'pg_stash_advice--1.0.sql',
+  'pg_stash_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_stash_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pg_stash_advice',
+    ],
+  },
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
new file mode 100644
index 00000000000..88dedd8ef1b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit
+
+CREATE FUNCTION pg_create_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_create_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_drop_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_drop_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint,
+									  advice_string text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_set_stashed_advice'
+LANGUAGE C;
+
+CREATE FUNCTION pg_get_advice_stashes(
+	OUT stash_name text,
+	OUT num_entries bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stashes'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_advice_stash_contents(
+	INOUT stash_name text,
+	OUT query_id bigint,
+	OUT advice_string text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
+LANGUAGE C;
+
+REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
new file mode 100644
index 00000000000..22122236694
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -0,0 +1,900 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.c
+ *	  Apply plan advice automatically, without SQL modifications.
+ *
+ * This module allows plan advice strings (as used and generated by
+ * pg_plan_advice) to be "stashed" in dynamic shared memory and, from
+ * there, automatically be applied to queries as they are planned.
+ * You can create any number of advice stashes, each of which is
+ * identified by a human-readable, ASCII name, and each of them is
+ * essentially a query ID -> advice_string mapping.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "common/string.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "lib/dshash.h"
+#include "nodes/queryjumble.h"
+#include "pg_plan_advice.h"
+#include "storage/dsm_registry.h"
+#include "storage/lwlock.h"
+#include "utils/builtins.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+#include "utils/tuplestore.h"
+
+PG_MODULE_MAGIC;
+
+PG_FUNCTION_INFO_V1(pg_create_advice_stash);
+PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
+PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
+PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
+PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+
+typedef struct pgsa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	int			stash_tranche;
+	int			entry_tranche;
+	uint64		next_stash_id;
+	dsa_handle	area;
+	dshash_table_handle stash_hash;
+	dshash_table_handle entry_hash;
+} pgsa_shared_state;
+
+typedef struct pgsa_stash
+{
+	char		name[NAMEDATALEN];
+	uint64		pgsa_stash_id;
+} pgsa_stash;
+
+typedef struct pgsa_entry_key
+{
+	uint64		pgsa_stash_id;
+	int64		queryId;
+} pgsa_entry_key;
+
+typedef struct pgsa_entry
+{
+	pgsa_entry_key key;
+	dsa_pointer advice_string;
+} pgsa_entry;
+
+typedef struct pgsa_stash_count
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	int64		num_entries;
+} pgsa_stash_count;
+
+#define SH_PREFIX pgsa_stash_count_table
+#define SH_ELEMENT_TYPE pgsa_stash_count
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct pgsa_stash_name
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	char	   *name;
+} pgsa_stash_name;
+
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/* Shared memory pointers */
+static pgsa_shared_state *pgsa_state;
+static dsa_area *pgsa_dsa_area;
+static dshash_table *pgsa_stash_dshash;
+static dshash_table *pgsa_entry_dshash;
+
+/* Shared memory hash table parameters */
+static dshash_parameters pgsa_stash_dshash_parameters = {
+	NAMEDATALEN,
+	sizeof(pgsa_stash),
+	dshash_strcmp,
+	dshash_strhash,
+	dshash_strcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+static dshash_parameters pgsa_entry_dshash_parameters = {
+	sizeof(pgsa_entry_key),
+	sizeof(pgsa_entry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+/* GUC variable */
+static char *pg_stash_advice_stash_name = "";
+
+/* Other global variables */
+static MemoryContext pg_stash_advice_mcxt;
+
+/* Function prototypes */
+static char *pgsa_advisor(PlannerGlobal *glob,
+						  Query *parse,
+						  const char *query_string,
+						  int cursorOptions,
+						  ExplainState *es);
+static void pgsa_attach(void);
+static void pgsa_check_stash_name(char *stash_name);
+static bool pgsa_check_stash_name_guc(char **newval, void **extra,
+									  GucSource source);
+static void pgsa_clear_advice_string(char *stash_name, int64 queryId);
+static void pgsa_create_stash(char *stash_name);
+static void pgsa_drop_stash(char *stash_name);
+static void pgsa_init_shared_state(void *ptr, void *arg);
+static uint64 pgsa_lookup_stash_id(char *stash_name);
+static void pgsa_set_advice_string(char *stash_name, int64 queryId,
+								   char *advice_string);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/* If compute_query_id = 'auto', we would like query IDs. */
+	EnableQueryId();
+
+	/* Define our GUCs. */
+	DefineCustomStringVariable("pg_stash_advice.stash_name",
+							   "Name of the advice stash to be used in this session.",
+							   NULL,
+							   &pg_stash_advice_stash_name,
+							   "",
+							   PGC_USERSET,
+							   0,
+							   pgsa_check_stash_name_guc,
+							   NULL,
+							   NULL);
+
+	MarkGUCPrefixReserved("pg_stash_advice");
+
+	/* Tell pg_plan_advice that we want to provide advice strings. */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+	(*add_advisor_fn) (pgsa_advisor);
+}
+
+/*
+ * SQL-callable function to create an advice stash
+ */
+Datum
+pg_create_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_create_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to drop an advice stash
+ */
+Datum
+pg_drop_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_drop_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to provide a list of advice stashes
+ */
+Datum
+pg_get_advice_stashes(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	pgsa_stash_count_table_hash *chash;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Tally up the number of entries per stash. */
+	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		pgsa_stash_count *c;
+		bool		found;
+
+		c = pgsa_stash_count_table_insert(chash,
+										  entry->key.pgsa_stash_id,
+										  &found);
+		if (!found)
+			c->num_entries = 1;
+		else
+			c->num_entries++;
+	}
+	dshash_seq_term(&iterator);
+
+	/* Emit results. */
+	dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[2];
+		bool		nulls[2];
+		pgsa_stash_count *c;
+
+		values[0] = CStringGetTextDatum(stash->name);
+		nulls[0] = false;
+
+		c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id);
+		values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries);
+		nulls[1] = false;
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to provide advice stash contents
+ */
+Datum
+pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	char	   *stash_name = NULL;
+	pgsa_stash_name_table_hash *nhash = NULL;
+	uint64		stash_id = 0;
+	pgsa_entry *entry;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* User can pass NULL for all stashes, or the name of a specific stash. */
+	if (!PG_ARGISNULL(0))
+	{
+		stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		pgsa_check_stash_name(stash_name);
+		stash_id = pgsa_lookup_stash_id(stash_name);
+
+		/* If the user specified a stash name, it should exist. */
+		if (stash_id == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("advice stash \"%s\" does not exist", stash_name));
+	}
+	else
+	{
+		pgsa_stash *stash;
+
+		/*
+		 * If we're dumping data about all stashes, we need an ID->name lookup
+		 * table.
+		 */
+		nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+		dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+		while ((stash = dshash_seq_next(&iterator)) != NULL)
+		{
+			pgsa_stash_name *n;
+			bool		found;
+
+			n = pgsa_stash_name_table_insert(nhash,
+											 stash->pgsa_stash_id,
+											 &found);
+			Assert(!found);
+			n->name = pstrdup(stash->name);
+		}
+		dshash_seq_term(&iterator);
+	}
+
+	/* Now iterate over all the entries. */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, false);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		char	   *this_stash_name;
+		char	   *advice_string;
+
+		/* Skip incomplete entries where the advice string was never set. */
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		if (stash_id != 0)
+		{
+			/*
+			 * We're only dumping data for one particular stash, so skip
+			 * entries for any other stash and use the stash name specified by
+			 * the user.
+			 */
+			if (stash_id != entry->key.pgsa_stash_id)
+				continue;
+			this_stash_name = stash_name;
+		}
+		else
+		{
+			pgsa_stash_name *n;
+
+			/*
+			 * We're dumping data for all stashes, so look up the correct name
+			 * to use in the hash table. If nothing is found, which is
+			 * possible due to race conditions, make up a string to use.
+			 */
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n != NULL)
+				this_stash_name = n->name;
+			else
+				this_stash_name = psprintf("<stash %" PRIu64 ">",
+										   entry->key.pgsa_stash_id);
+		}
+
+		/* Work out tuple values. */
+		values[0] = CStringGetTextDatum(this_stash_name);
+		nulls[0] = false;
+		values[1] = Int64GetDatum(entry->key.queryId);
+		nulls[1] = false;
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+		values[2] = CStringGetTextDatum(advice_string);
+		nulls[2] = false;
+
+		/* Emit the tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to update an advice stash entry for a particular
+ * query ID
+ *
+ * If the second argument is NULL, we delete any existing advice stash
+ * entry; otherwise, we either create an entry or update it with the new
+ * advice string.
+ */
+Datum
+pg_set_stashed_advice(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name;
+	int64		queryId;
+
+	if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+		PG_RETURN_NULL();
+
+	/* Get and check advice stash name. */
+	stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	pgsa_check_stash_name(stash_name);
+
+	/*
+	 * Get and check query ID.
+	 *
+	 * queryID 0 means no query ID was computed, so reject that.
+	 */
+	queryId = PG_GETARG_INT64(1);
+	if (queryId == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("cannot set advice string for query ID 0"));
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Now call the appropriate function to do the real work. */
+	if (PG_ARGISNULL(2))
+		pgsa_clear_advice_string(stash_name, queryId);
+	else
+	{
+		char	   *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+		pgsa_set_advice_string(stash_name, queryId, advice_string);
+	}
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * Get the advice string that has been configured for this query, if any,
+ * and return it. Otherwise, return NULL.
+ */
+static char *
+pgsa_advisor(PlannerGlobal *glob, Query *parse,
+			 const char *query_string, int cursorOptions,
+			 ExplainState *es)
+{
+	pgsa_entry_key key;
+	pgsa_entry *entry;
+	char	   *advice_string;
+	uint64		stash_id;
+
+	/*
+	 * Exit quickly if the stash name is empty or there's no query ID.
+	 */
+	if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0)
+		return NULL;
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/*
+	 * Translate pg_stash_advice.stash_name to an integer ID.
+	 *
+	 * pgsa_check_stash_name_guc() has already validated the advice stash
+	 * name, so we don't need to call pgsa_check_stash_name() here.
+	 */
+	stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name);
+	if (stash_id == 0)
+		return NULL;
+
+	/*
+	 * Look up the advice string for the given stash ID + query ID.
+	 *
+	 * If we find an advice string, we copy it into the current memory
+	 * context, presumably short-lived, so that we can release the lock on the
+	 * dshash entry. pg_plan_advice only needs the value to remain allocated
+	 * long enough for it to be parsed, so this should be good enough.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = parse->queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, false);
+	if (entry == NULL)
+		return NULL;
+	if (entry->advice_string == InvalidDsaPointer)
+		advice_string = NULL;
+	else
+		advice_string = pstrdup(dsa_get_address(pgsa_dsa_area,
+												entry->advice_string));
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we found an advice string, emit a debug message. */
+	if (advice_string != NULL)
+		elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s",
+			 pg_stash_advice_stash_name, key.queryId, advice_string);
+
+	return advice_string;
+}
+
+/*
+ * Attach to various structures in dynamic shared memory.
+ *
+ * This function is designed to be resilient against errors. That is, if it
+ * fails partway through, it should be possible to call it again, repeat no
+ * work already completed, and potentially succeed or at least get further if
+ * whatever caused the previous failure has been corrected.
+ */
+static void
+pgsa_attach(void)
+{
+	bool		found;
+	MemoryContext oldcontext;
+
+	/*
+	 * Create a memory context to make sure that any control structures
+	 * allocated in local memory are sufficiently persistent.
+	 */
+	if (pg_stash_advice_mcxt == NULL)
+		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
+													 "pg_stash_advice",
+													 ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
+
+	/* Attach to the fixed-size state object if not already done. */
+	if (pgsa_state == NULL)
+		pgsa_state = GetNamedDSMSegment("pg_stash_advice",
+										sizeof(pgsa_shared_state),
+										pgsa_init_shared_state,
+										&found, NULL);
+
+	/* Attach to the DSA area if not already done. */
+	if (pgsa_dsa_area == NULL)
+	{
+		dsa_handle	area_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		area_handle = pgsa_state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
+			dsa_pin(pgsa_dsa_area);
+			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_dsa_area = dsa_attach(area_handle);
+		}
+		dsa_pin_mapping(pgsa_dsa_area);
+	}
+
+	/* Attach to the stash_name->stash_id hash table if not already done. */
+	if (pgsa_stash_dshash == NULL)
+	{
+		dshash_table_handle stash_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
+		stash_handle = pgsa_state->stash_hash;
+		if (stash_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  NULL);
+			pgsa_state->stash_hash =
+				dshash_get_hash_table_handle(pgsa_stash_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  stash_handle, NULL);
+		}
+	}
+
+	/* Attach to the entry hash table if not already done. */
+	if (pgsa_entry_dshash == NULL)
+	{
+		dshash_table_handle entry_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
+		entry_handle = pgsa_state->entry_hash;
+		if (entry_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  NULL);
+			pgsa_state->entry_hash =
+				dshash_get_hash_table_handle(pgsa_entry_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  entry_handle, NULL);
+		}
+	}
+
+	/* Restore previous memory context. */
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Check whether an advice stash name is legal, and signal an error if not.
+ *
+ * Keep this in sync with pgsa_check_stash_name_guc, below.
+ */
+static void
+pgsa_check_stash_name(char *stash_name)
+{
+	/* Reject empty advice stash name. */
+	if (stash_name[0] == '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name may not be zero length"));
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash names may not be longer than %d bytes",
+					   NAMEDATALEN - 1));
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must not contain non-ASCII characters"));
+}
+
+/*
+ * As above, but for the GUC check_hook. We allow the empty string here,
+ * though, as equivalent to disabling the feature.
+ */
+static bool
+pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source)
+{
+	char	   *stash_name = *newval;
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash names may not be longer than %d bytes",
+							NAMEDATALEN - 1);
+		return false;
+	}
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must not contain non-ASCII characters");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Create an advice stash.
+ */
+static void
+pgsa_create_stash(char *stash_name)
+{
+	pgsa_stash *stash;
+	bool		found;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Create a stash with this name, unless one already exists. */
+	stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found);
+	if (found)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" already exists", stash_name));
+	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+}
+
+/*
+ * Remove any stored advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_clear_advice_string(char *stash_name, int64 queryId)
+{
+	pgsa_entry *entry;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer old_dp;
+
+	/* Translate the stash name to an integer ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/*
+	 * Look for an existing entry, and free it. But, be sure to save the
+	 * pointer to the associated advice string, if any.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		old_dp = InvalidDsaPointer;
+	else
+	{
+		old_dp = entry->advice_string;
+		dshash_delete_entry(pgsa_entry_dshash, entry);
+	}
+
+	/* Now we free the advice string as well, if there was one. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
+
+/*
+ * Drop an advice stash.
+ */
+static void
+pgsa_drop_stash(char *stash_name)
+{
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	dshash_seq_status iterator;
+	uint64		stash_id;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove the entry for this advice stash. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, true);
+	if (stash == NULL)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+	stash_id = stash->pgsa_stash_id;
+	dshash_delete_entry(pgsa_stash_dshash, stash);
+
+	/*
+	 * Now remove all the entries. Since pgsa_state->lock must be held at
+	 * least in shared mode to insert entries into pgsa_entry_dshash, it
+	 * doesn't matter whether we do this before or after deleting the entry
+	 * from pgsa_stash_dshash.
+	 */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		if (stash_id == entry->key.pgsa_stash_id)
+		{
+			if (entry->advice_string != InvalidDsaPointer)
+				dsa_free(pgsa_dsa_area, entry->advice_string);
+			dshash_delete_current(&iterator);
+		}
+	}
+	dshash_seq_term(&iterator);
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgsa_init_shared_state(void *ptr, void *arg)
+{
+	pgsa_shared_state *state = (pgsa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_stash_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
+	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
+	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
+	state->next_stash_id = UINT64CONST(1);
+	state->area = DSA_HANDLE_INVALID;
+	state->stash_hash = DSHASH_HANDLE_INVALID;
+	state->entry_hash = DSHASH_HANDLE_INVALID;
+}
+
+/*
+ * Look up the integer ID that corresponds to the given stash name.
+ *
+ * Returns 0 if no such stash exists.
+ */
+static uint64
+pgsa_lookup_stash_id(char *stash_name)
+{
+	pgsa_stash *stash;
+	uint64		stash_id;
+
+	/* Search the shared hash table. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, false);
+	if (stash == NULL)
+		return 0;
+	stash_id = stash->pgsa_stash_id;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	return stash_id;
+}
+
+/*
+ * Store a new or updated advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
+{
+	pgsa_entry *entry;
+	bool		found;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer new_dp;
+	dsa_pointer old_dp;
+
+	/*
+	 * The work we need to do in this function is basically simple, but the
+	 * danger of a server-lifespan DSA memory leak is very real. Acquiring a
+	 * lock here helps for two reasons.
+	 *
+	 * First, it holds off interrupts, so that we can't bail out of this code
+	 * after allocating DSA memory for the advice string and before storing
+	 * the resulting pointer somewhere that others can find it.
+	 *
+	 * Second, we need to avoid a race against pgsa_drop_stash(). That
+	 * function removes a stash_name->stash_id mapping and all the entries for
+	 * that stash_id. Without the lock, there's a race condition no matter
+	 * which of those things it does first, because as soon as we've looked up
+	 * the stash ID, that whole function can execute before we do the rest of
+	 * our work, which would result in us adding an entry for a stash that no
+	 * longer exists.
+	 */
+	LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+
+	/* Look up the stash ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/* Allocate space for the advice string. */
+	new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1);
+	strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string);
+
+	/* Attempt to insert an entry into the hash table. */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find_or_insert_extended(pgsa_entry_dshash, &key, &found,
+										   DSHASH_INSERT_NO_OOM);
+
+	/*
+	 * If it didn't work, bail out, being careful to free the shared memory
+	 * we've already allocated before throwing an error, since error cleanup
+	 * will not do so.
+	 */
+	if (entry == NULL)
+	{
+		dsa_free(pgsa_dsa_area, new_dp);
+		ereport(ERROR,
+				errcode(ERRCODE_OUT_OF_MEMORY),
+				errmsg("out of memory"),
+				errdetail("could not insert advice string into shared hash table"));
+	}
+
+	/* Update the entry and release the lock. */
+	old_dp = found ? entry->advice_string : InvalidDsaPointer;
+	entry->advice_string = new_dp;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/*
+	 * We're not safe from leaks yet!
+	 *
+	 * There's now a pointer to new_dp in the entry that we just updated, but
+	 * that means that there's no longer anything pointing to old_dp. Free it
+	 * first, and then we can release our last LWLock, allowing interrupts.
+	 */
+	if (DsaPointerIsValid(old_dp))
+		dsa_free(pgsa_dsa_area, old_dp);
+	LWLockRelease(&pgsa_state->lock);
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control
new file mode 100644
index 00000000000..4a0fff5c866
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.control
@@ -0,0 +1,5 @@
+# pg_stash_advice extension
+comment = 'store and automatically apply plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_stash_advice'
+relocatable = true
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
new file mode 100644
index 00000000000..3f6bfb83114
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
@@ -0,0 +1,147 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+SET pg_stash_advice.stash_name = 'regress_stash';
+
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('no_such_stash');
+
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+
+-- Stash names must be non-empty, ASCII, and not too long.
+SELECT pg_create_advice_stash('');
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+SELECT pg_create_advice_stash(E'caf\u00e9');
+SET pg_stash_advice.stash_name = 'café';
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('regress_empty_stash');
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index 2ab6fafbab1..8f09d728698 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -160,6 +160,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 407ff3abffe..8c14bab84e9 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -144,6 +144,7 @@
 <!ENTITY oid2name        SYSTEM "oid2name.sgml">
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
+<!ENTITY pgstashadvice   SYSTEM "pgstashadvice.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
 <!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
new file mode 100644
index 00000000000..089fc66446f
--- /dev/null
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -0,0 +1,218 @@
+<!-- doc/src/sgml/pgstashadvice.sgml -->
+
+<sect1 id="pgstashadvice" xreflabel="pg_stash_advice">
+ <title>pg_stash_advice &mdash; store and automatically apply plan advice</title>
+
+ <indexterm zone="pgstashadvice">
+  <primary>pg_stash_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_stash_advice</filename> extension allows you to stash
+  <link linkend="pgplanadvice">plan advice</link> strings in dynamic
+  shared memory where they can be automatically applied. An
+  <literal>advice stash</literal> is a mapping from
+  <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
+  strings. Whenever a session is asked to plan a query whose query ID appears
+  in the relevant advice stash, the plan advice string is automatically applied
+  to guide planning. Note that advice stashes exist purely in memory. This
+  means both that it is important to be mindful of memory consumption when
+  deciding how much plan advice to stash, and also that advice stashes must
+  be recreated and repopulated whenever the server is restarted.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
+  one database, so that you have access to the SQL functions to manage
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
+  module to be loaded in all sessions where you want this module to
+  automatically apply advice. It will usually be best to do this by adding
+  <literal>pg_stash_advice</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> and restarting the server.
+ </para>
+
+ <para>
+  Once you have met the above criteria, you can create advice stashes
+  using the <literal>pg_create_advice_stash</literal> function described
+  below and set the plan advice for a given query ID in a given stash using
+  the <literal>pg_set_stashed_advice</literal> function. Then, you need
+  only configure <literal>pg_stash_advice.stash_name</literal> to point
+  to the chosen advice stash name. For some use cases, rather than setting
+  this on a system-wide basis, you may find it helpful to use
+  <literal>ALTER DATABASE ... SET</literal> or
+  <literal>ALTER ROLE ... SET</literal> to configure values that will apply
+  only to a database or only to a certain role. Likewise, it may sometimes
+  be better to set the stash name in a particular session using
+  <literal>SET</literal>.
+ </para>
+
+ <para>
+  Because <literal>pg_stash_advice</literal> works on the basis of query
+  identifiers, you will need to determine the query identifier for each query
+  whose plan you wish to control. You will also need to determine the advice
+  string that you wish to store for each query. One way to do this is to use
+  <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
+  show the query ID, and the <literal>PLAN_ADVICE</literal> option will
+  show plan advice. <xref linkend="pgcollectadvice" /> can be used to
+  obtain this information for an entire workload, although care must be
+  taken since it can use up a lot of memory very quickly. Query identifiers can
+  also be obtained through tools such as <xref linkend="pgstatstatements" />
+  or <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
+  will not provide plan advice strings. Note that
+  <xref linkend="guc-compute-query-id" /> must be enabled for query
+  identifiers to be computed; if set to <literal>auto</literal>, loading
+  <literal>pg_stash_advice</literal> will enable it automatically.
+ </para>
+
+ <para>
+  Generally, the fact that the planner is able to change query plans as
+  the underlying distribution of data changes is a feature, not a bug.
+  Moreover, applying plan advice can have a noticeable performance cost even
+  when it does not result in a change to the query plan. Therefore, it is
+  a good idea to use this feature only when and to the extent needed.
+  Plan advice strings can be trimmed down to mention only those aspects
+  of the plan that need to be controlled, and used only for queries where
+  there is believed to be a significant risk of planner error.
+ </para>
+
+ <para>
+  Note that <literal>pg_stash_advice</literal> currently lacks a sophisticated
+  security model. Only the superuser, or a user to whom the superuser has
+  granted <literal>EXECUTE</literal> permission on the relevant functions,
+  may create advice stashes or alter their contents, but any user may set
+  <literal>pg_stash_advice.stash_name</literal> for their session, and this
+  may reveal the contents of any advice stash with that name. Users should
+  assume that information embedded in stashed advice strings may become visible
+  to nonprivileged users.
+ </para>
+
+ <sect2 id="pgstashadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_create_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_create_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Creates a new, empty advice stash with the given name.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_drop_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_drop_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Drops the named advice stash and all of its entries.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_set_stashed_advice(stash_name text, query_id bigint,
+       advice_string text) returns void</function>
+     <indexterm>
+      <primary>pg_set_stashed_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Stores an advice string in the named advice stash, associated with
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
+      <parameter>advice_string</parameter> is <literal>NULL</literal>,
+      any existing entry for that query identifier is removed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stashes() returns setof (stash_name text,
+       num_entries bigint)</function>
+     <indexterm>
+      <primary>pg_get_advice_stashes</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each advice stash, showing the stash name and
+      the number of entries it contains.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stash_contents(stash_name text) returns setof
+       (stash_name text, query_id bigint, advice_string text)</function>
+     <indexterm>
+      <primary>pg_get_advice_stash_contents</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each entry in the named advice stash. If
+      <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
+      entries from all stashes.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.stash_name</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the name of the advice stash to consult during query
+      planning. The default value is the empty string, which disables
+      this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 7349c06a067..0000e56d55c 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4056,6 +4056,12 @@ pgpa_trove_lookup_type
 pgpa_trove_result
 pgpa_trove_slice
 pgpa_unrolled_join
+pgsa_entry
+pgsa_entry_key
+pgsa_shared_state
+pgsa_stash
+pgsa_stash_count
+pgsa_stash_name
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-25 23:59  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-03-25 23:59 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

Hi Robert,

On Tue, Mar 24, 2026 at 2:10 PM Robert Haas <[email protected]> wrote:
>
> On Sat, Mar 21, 2026 at 9:13 AM Robert Haas <[email protected]> wrote:
> > So I'm left with the idea that to get test_plan_advice to be fully
> > stable on these slower machines, it will probably be necessary to make
> > it control which AlternativeSubPlan is chosen and whether a
> > MinMaxAggPath is chosen or not. I have some ideas about how to
> > accomplish that in a reasonably elegant fashion without adding too
> > much new machinery, but I need to spend some more time validating
> > those ideas before committing to a precise course of action. More
> > soon.
>
> Here is v22. There are four new patches.
>
> 0001 adds a disabled_nodes fields to SubPlan, to fix the bug that I
> identified in the email to which this is a reply.

This looks good, and is consistent with how it would have worked
before the introduction of disabled nodes (since costs would have just
been very high and thus discourage a subplan with many disabled
nodes).

Only nit is that the commit hash reference in the commit message
doesn't seem right, I think you probably meant
e22253467942fdb100087787c3e1e3a8620c54b2

> 0002-0004 are an attempt to fix the remaining buildfarm failures not
> already addressed (or attempted to be addressed, anyway) by other
> commits. The basic idea, implemented by 0004, is to add a
> DO_NOT_SCAN() advice tag. This advice is generated when we consider a
> MinMaxAggPath or a hashed SubPlan. In either case, all relations which
> are part of the non-selected alternative are marked DO_NOT_SCAN(),
> which works like scan type advice but disables every possible scan
> type rather than still allowing exactly one of them. Unless I've
> missed something, this should be sufficient to make pg_plan_advice
> stabilize which of two alternative SubPlans we pick and whether or not
> a min/max aggregate is chosen. 0002 does some preliminary refactoring
> to provide a more centralized way of tracking per-PlannerInfo details
> within pg_plan_advice. 0003 makes the necessary change to
> src/backend/optimizer, which consists of adding an alternative_root
> field to each PlannerInfo and setting it appropriately. 0004 then
> updates pg_plan_advice to implement DO_NOT_SCAN().

For 0002:

I think that overall looks like a good refactoring, with two minor notes:

> diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
> index fee88904760..70139ff42be 100644
> --- a/contrib/pg_plan_advice/pgpa_planner.c
> +++ b/contrib/pg_plan_advice/pgpa_planner.c
> ...
> @@ -2017,34 +1949,64 @@ pgpa_planner_feedback_warning(List *feedback)
>                  errdetail("%s", detailbuf.data));
>  }
>
> -#ifdef USE_ASSERT_CHECKING
> -
>  /*
> - * Fast hash function for a key consisting of an RTI and plan name.
> + * Get or create the pgpa_planner_info for the subroot with the given
> + * plan_name.
>  */
> -static uint32
> -pgpa_ri_checker_hash_key(pgpa_ri_checker_key key)
> +static pgpa_planner_info *
> +pgpa_planner_get_proot(pgpa_planner_state *pps, PlannerInfo *root)
>  {

I'd word that as "Get or create the pgpa_planner_info for the given
PlannerInfo and its associated plan_name", since you're not passing a
plan_name as the argument.

> @@ -2053,19 +2015,34 @@ pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
>                      RelOptInfo *rel)
> {
> ...
> +   if (proot->rid_array_size <= rel->relid)
> +   {
> +           int                     new_size = Max(proot->rid_array_size, 8);
> +
> +           while (new_size < rel->relid)
> +                   new_size *= 2;

This could use pg_nextpower2_32 on the rel->relid instead of the
manual while loop.

---

For 0003:

I wonder if "original_root" wouldn't be more correct here as a name
(instead of "alternative_root"), since if I follow the implementation
correctly, you are adding a pointer on each alternative root, back to
the original root that the alternative was copied from.

I also wonder if maybe we should be more narrow in what we keep here.
It seems 0004 mainly needs the original plan name, so maybe its better
if we just keep that for targeting purposes, vs a full pointer to the
PlannerInfo. The planner makes an effort to zap unused subplans at the
end of set_plan_references, and I think this new field would then be
the only pointer to those unused subplans. If we decided to add an
early free there at some point (instead of just making them a NULL
entry in the list), that'd break pg_plan_advice.

---

For 0004:

> +    /*
> +     * 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;

Per the earlier note, I think using "original_plan_name" would make
more sense here, because it'll be the name the alternatives are based
on. I also think "has_alternative_plan" is more clear for the boolean,
since it'll be set on the info for the original info as well, if I
understand correctly.

Otherwise 0004 looks like a reasonable compromise for now. I feel like
we can find better ways of doing this over time, and there are parts
I'm not excited about (e.g. the targeting feels a bit brittle when it
comes to anything that'd cause generated alternative subplan names to
change), but I think it works for now.

> 0005 is the pg_collect_advice module from previous versions of the
> patch set. The main change here is that I completely rewrote the TAP
> test, which previously was running the entire regression test suite
> yet another time. That's been replaced with something that is much
> faster and much better targeted at properly testing the shared advice
> collector. Aside from that, I added one more check for
> InvalidDsaPointer where the code was previously lacking one.



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-26 13:55  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 2 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-26 13:55 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Mar 25, 2026 at 7:59 PM Lukas Fittl <[email protected]> wrote:
> > 0001 adds a disabled_nodes fields to SubPlan, to fix the bug that I
> > identified in the email to which this is a reply.
>
> This looks good, and is consistent with how it would have worked
> before the introduction of disabled nodes (since costs would have just
> been very high and thus discourage a subplan with many disabled
> nodes).
>
> Only nit is that the commit hash reference in the commit message
> doesn't seem right, I think you probably meant
> e22253467942fdb100087787c3e1e3a8620c54b2

Whoops. Obviously got the wrong thing stuck in my cut and paste buffer
when I was writing that. Thanks for checking it. I'm going to go ahead
and commit this, because I'm pretty confident that it's correct, and
the rest of these patches are not going to fix the buildfarm
instability without it, and I'm pretty sure multiple committers are
pretty tired of seeing these test_plan_advice failures already.

> For 0002:
>
> I think that overall looks like a good refactoring, with two minor notes:
>
> I'd word that as "Get or create the pgpa_planner_info for the given
> PlannerInfo and its associated plan_name", since you're not passing a
> plan_name as the argument.

Right, the comment isn't quite correct. I don't think your rewording
is quite right either, though, because there's really no reason to
mention plan_name here at all. I'll adjust it.

> > @@ -2053,19 +2015,34 @@ pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
> >                      RelOptInfo *rel)
> > {
> > ...
> > +   if (proot->rid_array_size <= rel->relid)
> > +   {
> > +           int                     new_size = Max(proot->rid_array_size, 8);
> > +
> > +           while (new_size < rel->relid)
> > +                   new_size *= 2;
>
> This could use pg_nextpower2_32 on the rel->relid instead of the
> manual while loop.

OK, will change that also, and then also commit this one.

> For 0003:
>
> I wonder if "original_root" wouldn't be more correct here as a name
> (instead of "alternative_root"), since if I follow the implementation
> correctly, you are adding a pointer on each alternative root, back to
> the original root that the alternative was copied from.

To me, that seems less clear. Someone might think that the original
root means the toplevel PlannerInfo, or that whatever PlannerInfo we
had around when we created the current PlannerInfo will be the
original_root. But in fact we are only using this in a much more
narrow situation, namely, when we're creating a new PlannerInfo as a
way to consider an alternative implementation of the same portion of
the query. That is, I think alternative conveys a sibling
relationship, and original doesn't necessarily do so.

> I also wonder if maybe we should be more narrow in what we keep here.
> It seems 0004 mainly needs the original plan name, so maybe its better
> if we just keep that for targeting purposes, vs a full pointer to the
> PlannerInfo. The planner makes an effort to zap unused subplans at the
> end of set_plan_references, and I think this new field would then be
> the only pointer to those unused subplans. If we decided to add an
> early free there at some point (instead of just making them a NULL
> entry in the list), that'd break pg_plan_advice.

The dangling pointers are a good point; I agree that's bad. However,
I'd be more inclined to fix it by nulling out the alternative_root
pointers at the end of set_plan_references. I think that would just be
the case where root->isAltSubplan[ndx] && root->isUsedSubplan[ndx].
The reason I'm reluctant to just store the name is that there's not an
easy way to find a PlannerInfo by name. I originally proposed an
"allroots" list in PlannerGlobal, but we went with subplanNames on
Tom's suggestion. I subsequently realized that this kind of stinks for
code that is trying to use this infrastructure for anything, for
exactly this reason, but Tom never responded and I never pressed the
issue. But I think we're boxing ourselves into a corner if we just
keep storing names that can't be looked up everywhere. It doesn't
matter for the issue before us, so maybe doing as you say here is the
right idea just so we can move forward, but I think we're probably
kidding ourselves a little bit.

> From a design perspective, I'm -1 on storing of full query text
> strings in shared memory when the shared collector mode. With large
> query texts and without an aggregate MB size limit that's an
> expressway into OOM land, even if you used a low value like 100
> entries max, because ORMs are just really good at creating large query
> texts unexpectedly. I'm also skeptical whether that's a good idea for
> the local collection mode, but it'd be less problematic there.
> Overall, I think this needs to rely on queryid instead and not store
> query texts. I would not make queryid optional, but instead enable it
> automatically - which fits together with pg_stash_advice taking it as
> input.
>
> I realize not having query texts reduces its effectiveness (since you
> don't see which parameters produced which plan advice), but it still
> helps surface which different advice strings where seen for which
> query IDs, letting you identify if you're getting a mix of bad and
> good plans. And I'm just really worried people will enable this on
> production in shared collection mode and take down their system.

I fully admit that pg_collect_advice is crude, but I don't think
ripping out some portion of the limited functionality that it has is
going to get us where we want to be. If it hadn't collected the query
strings, it would have been useless for the purpose for which I
originally wrote it. We could add a GUC for a length limit, perhaps,
but I think the real feature that this needs to be used in the way
that you seem to want to use it is deduplication, and as I said
earlier, I think we should consider adding the advice collection logic
to pg_stat_statements rather than building an alternative version of
that module with overlapping functionality. If you think this is a
sufficiently large foot-gun that we shouldn't ship it, then I'll just
drop this patch for this cycle and we can revisit what to do for next
cycle. It's not critical, and with more time we can talk through
possible approaches and have time to code something up in a
responsible way. There's just no time to make big design changes right
now. If some small adjustment (like adding a GUC to limit the length
of the query string we're willing to collect) elevates it to
acceptability then I'm happy to do that, but otherwise we should just
revisit the topic for next cycle.

> From a design perspective, I'm worried about the fact that we lose the
> stashed advice on a restart. e.g. imagine a DBA using pg_stash_advice
> to pin a query that sometimes picks a bad plan to the good plan, but
> then their cloud provider applies a security update overnight.
> Suddenly the database is slow because the bad plans are being picked
> again.
>
> pg_hint_plan's solution to this (the "hints" table [0]) uses an actual
> table managed by the extension - but I suspect that doesn't fit the
> picture, since it'd be per database, etc. It does have the benefit of
> being restart safe though, and being copied to replicas.
>
> I wonder if we could find a way to dump and restore the advice stash
> information via a file, so its at least crash and restart safe?

I think if we want to ship this extension in this release, then the
only alternative is to tell users they have to put in place a manual
process for this. That is not great, but the original version of
pg_prewarm had the exact same issue, and I don't think anyone thought
that made it useless. Indeed, pginfcore still has nothing comparable
to autoprewarm, and I'm not saying that as a way of throwing shade on
pgfincore.

What I'm concerned about with this module is completely different: I'm
wondering whether the approach it takes to using DSA is OK, whether
the security model is adequate, and that sort of thing. Expanding the
scope is 100% off the table in my book. Feature freeze is in less than
two weeks.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-26 14:30  Matheus Alcantara <[email protected]>
  parent: Robert Haas <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Matheus Alcantara @ 2026-03-26 14:30 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; Lukas Fittl <[email protected]>; +Cc: Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On 26/03/26 10:55, Robert Haas wrote:
>> I realize not having query texts reduces its effectiveness (since you
>> don't see which parameters produced which plan advice), but it still
>> helps surface which different advice strings where seen for which
>> query IDs, letting you identify if you're getting a mix of bad and
>> good plans. And I'm just really worried people will enable this on
>> production in shared collection mode and take down their system.
> 
> I fully admit that pg_collect_advice is crude, but I don't think
> ripping out some portion of the limited functionality that it has is
> going to get us where we want to be. If it hadn't collected the query
> strings, it would have been useless for the purpose for which I
> originally wrote it. We could add a GUC for a length limit, perhaps,
> but I think the real feature that this needs to be used in the way
> that you seem to want to use it is deduplication, and as I said
> earlier, I think we should consider adding the advice collection logic
> to pg_stat_statements rather than building an alternative version of
> that module with overlapping functionality.
>

I also think that we should consider adding the advice string on 
pg_stat_statements. It seems to make more sense to me IMHO.

Adding support for auto_explain to explain(plan_advice, ...) (or any 
other custom explain option from loadable modules) would help or make 
sense here? I have been thinking about this for a while.


--
Matheus Alcantara
EDB: https://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-26 14:37  Robert Haas <[email protected]>
  parent: Matheus Alcantara <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-26 14:37 UTC (permalink / raw)
  To: Matheus Alcantara <[email protected]>; +Cc: Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 26, 2026 at 10:30 AM Matheus Alcantara
<[email protected]> wrote:
> Adding support for auto_explain to explain(plan_advice, ...) (or any
> other custom explain option from loadable modules) would help or make
> sense here? I have been thinking about this for a while.

I think that some generic support for custom explain options in
auto_explain is a good idea, but if you use that method to collect
advice strings, you're going to have quite a bit of log-filtering work
to do to get anything useful out of it. That might be fine for some
people, and it's certainly better than nothing, but I think eventually
we want a cleaner way. But still, +many for upgrading auto_explain
with this feature.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-26 17:20  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  1 sibling, 3 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-26 17:20 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 26, 2026 at 9:55 AM Robert Haas <[email protected]> wrote:
> Whoops. Obviously got the wrong thing stuck in my cut and paste buffer
> when I was writing that. Thanks for checking it. I'm going to go ahead
> and commit this, because I'm pretty confident that it's correct, and
> the rest of these patches are not going to fix the buildfarm
> instability without it, and I'm pretty sure multiple committers are
> pretty tired of seeing these test_plan_advice failures already.

Done.

> Right, the comment isn't quite correct. I don't think your rewording
> is quite right either, though, because there's really no reason to
> mention plan_name here at all. I'll adjust it.

Done and committed, after also adjusting the memory context handling
to avoid re-breaking GEQO.

> The dangling pointers are a good point; I agree that's bad. However,
> I'd be more inclined to fix it by nulling out the alternative_root
> pointers at the end of set_plan_references. I think that would just be
> the case where root->isAltSubplan[ndx] && root->isUsedSubplan[ndx].
> The reason I'm reluctant to just store the name is that there's not an
> easy way to find a PlannerInfo by name. I originally proposed an
> "allroots" list in PlannerGlobal, but we went with subplanNames on
> Tom's suggestion. I subsequently realized that this kind of stinks for
> code that is trying to use this infrastructure for anything, for
> exactly this reason, but Tom never responded and I never pressed the
> issue. But I think we're boxing ourselves into a corner if we just
> keep storing names that can't be looked up everywhere. It doesn't
> matter for the issue before us, so maybe doing as you say here is the
> right idea just so we can move forward, but I think we're probably
> kidding ourselves a little bit.

Here's a new version, where I've replaced alternative_root by
alternative_plan_name, serving the same function.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v23-0003-Add-pg_collect_advice-contrib-module.patch (56.5K, 2-v23-0003-Add-pg_collect_advice-contrib-module.patch)
  download | inline diff:
From 20331ab5502c48c05c4e26e5656c5e15ff91c9e5 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Thu, 26 Feb 2026 16:51:16 -0500
Subject: [PATCH v23 3/4] Add pg_collect_advice contrib module.

This module allows for bulk collection of queries and the associated
plan advice strings using either backend-local memory or dynamic
shared memory. In either case, memory usage can be limited by
restriction the maximum number of queries and advice strings stored.
Care should be taken with these values, and with the use of this
module in general, because it's easy to chew up an unreasonably large
amount of memory. Unlike pg_stat_statements, this module does not
provide for query normalization or even deduplication; it simply makes
a record for every query planned.

It can be useful to enable query ID computaton before using the
module, but it's not required. If not done, all queries will simply
show a query ID of zero.

Reviewed-by: Alexandra Wang <[email protected]>
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_collect_advice/Makefile            |  26 +
 contrib/pg_collect_advice/collector.c         | 649 ++++++++++++++++++
 .../expected/local_collector.out              |  69 ++
 contrib/pg_collect_advice/interface.c         | 303 ++++++++
 contrib/pg_collect_advice/meson.build         |  41 ++
 .../pg_collect_advice--1.0.sql                |  43 ++
 .../pg_collect_advice.control                 |   5 +
 contrib/pg_collect_advice/pg_collect_advice.h |  39 ++
 .../pg_collect_advice/sql/local_collector.sql |  46 ++
 .../t/001_shared_collector.pl                 | 154 +++++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgcollectadvice.sgml             | 244 +++++++
 src/tools/pgindent/typedefs.list              |   6 +
 16 files changed, 1629 insertions(+)
 create mode 100644 contrib/pg_collect_advice/Makefile
 create mode 100644 contrib/pg_collect_advice/collector.c
 create mode 100644 contrib/pg_collect_advice/expected/local_collector.out
 create mode 100644 contrib/pg_collect_advice/interface.c
 create mode 100644 contrib/pg_collect_advice/meson.build
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice--1.0.sql
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.control
 create mode 100644 contrib/pg_collect_advice/pg_collect_advice.h
 create mode 100644 contrib/pg_collect_advice/sql/local_collector.sql
 create mode 100644 contrib/pg_collect_advice/t/001_shared_collector.pl
 create mode 100644 doc/src/sgml/pgcollectadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index dd04c20acd2..22071034e51 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -31,6 +31,7 @@ SUBDIRS = \
 		pageinspect	\
 		passwordcheck	\
 		pg_buffercache	\
+		pg_collect_advice \
 		pg_freespacemap \
 		pg_logicalinspect \
 		pg_overexplain \
diff --git a/contrib/meson.build b/contrib/meson.build
index 5a752eac347..ff422d9b7fc 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -45,6 +45,7 @@ subdir('pageinspect')
 subdir('passwordcheck')
 subdir('pg_buffercache')
 subdir('pgcrypto')
+subdir('pg_collect_advice')
 subdir('pg_freespacemap')
 subdir('pg_logicalinspect')
 subdir('pg_overexplain')
diff --git a/contrib/pg_collect_advice/Makefile b/contrib/pg_collect_advice/Makefile
new file mode 100644
index 00000000000..594c1bf82b2
--- /dev/null
+++ b/contrib/pg_collect_advice/Makefile
@@ -0,0 +1,26 @@
+# contrib/pg_collect_advice/Makefile
+
+MODULE_big = pg_collect_advice
+OBJS = \
+	$(WIN32RES) \
+	collector.o \
+	interface.o
+
+EXTENSION = pg_collect_advice
+DATA = pg_collect_advice--1.0.sql
+PGFILEDESC = "pg_collect_advice - collect queries and their plan advice strings"
+
+REGRESS = local_collector
+EXTRA_INSTALL = contrib/pg_plan_advice
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_collect_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_collect_advice/collector.c b/contrib/pg_collect_advice/collector.c
new file mode 100644
index 00000000000..d9fc3238fbd
--- /dev/null
+++ b/contrib/pg_collect_advice/collector.c
@@ -0,0 +1,649 @@
+/*-------------------------------------------------------------------------
+ *
+ * collector.c
+ *	  workhorse for saving plan advice in backend-local or shared memory
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+#include "utils/tuplestore.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE		1024
+#define ADVICE_CHUNK_ARRAY_SIZE	64
+
+#define	PG_GET_ADVICE_COLUMNS	7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgca_collected_advice
+{
+	Oid			userid;			/* user OID */
+	Oid			dbid;			/* database OID */
+	uint64		queryid;		/* query identifier */
+	TimestampTz timestamp;		/* query timestamp */
+	int			advice_offset;	/* start of advice in textual data */
+	char		textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgca_collected_advice;
+
+/*
+ * A bunch of pointers to pgca_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgca_local_advice_chunk
+{
+	pgca_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgca_local_advice_chunk;
+
+/*
+ * Information about all of the pgca_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgca_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgca_local_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	pgca_local_advice_chunk **chunks;
+} pgca_local_advice;
+
+/*
+ * Just like pgca_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice_chunk
+{
+	dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgca_shared_advice_chunk;
+
+/*
+ * Just like pgca_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgca_shared_advice
+{
+	uint64		next_id;
+	uint64		oldest_id;
+	uint64		base_id;
+	int			chunk_array_allocated_size;
+	dsa_pointer chunks;
+} pgca_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgca_local_advice *local_collector = NULL;
+static pgca_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgca_collected_advice *make_collected_advice(Oid userid,
+													Oid dbid,
+													uint64 queryId,
+													TimestampTz timestamp,
+													const char *query_string,
+													const char *advice_string,
+													dsa_area *area,
+													dsa_pointer *result);
+static void store_local_advice(pgca_collected_advice *ca);
+static void trim_local_advice(int limit);
+static void store_shared_advice(dsa_pointer ca_pointer);
+static void trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgca_collected_advice */
+static inline const char *
+query_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgca_collected_advice */
+static inline const char *
+advice_string(pgca_collected_advice *ca)
+{
+	return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pg_collect_advice_save(uint64 queryId, const char *query_string,
+					   const char *advice_string)
+{
+	Oid			userid = GetUserId();
+	Oid			dbid = MyDatabaseId;
+	TimestampTz now = GetCurrentTimestamp();
+
+	if (pg_collect_advice_local_collector &&
+		pg_collect_advice_local_collection_limit > 0)
+	{
+		pgca_collected_advice *ca;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+		ca = make_collected_advice(userid, dbid, queryId, now,
+								   query_string, advice_string,
+								   NULL, NULL);
+		store_local_advice(ca);
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	if (pg_collect_advice_shared_collector &&
+		pg_collect_advice_shared_collection_limit > 0)
+	{
+		dsa_area   *area = pg_collect_advice_dsa_area();
+		dsa_pointer ca_pointer = InvalidDsaPointer; /* placate compiler */
+
+		make_collected_advice(userid, dbid, queryId, now,
+							  query_string, advice_string, area,
+							  &ca_pointer);
+		store_shared_advice(ca_pointer);
+	}
+}
+
+/*
+ * Allocate and fill a new pgca_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgca_collected_advice *
+make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+					  TimestampTz timestamp,
+					  const char *query_string,
+					  const char *advice_string,
+					  dsa_area *area, dsa_pointer *result)
+{
+	size_t		query_string_length = strlen(query_string) + 1;
+	size_t		advice_string_length = strlen(advice_string) + 1;
+	size_t		total_length;
+	pgca_collected_advice *ca;
+
+	total_length = offsetof(pgca_collected_advice, textual_data)
+		+ query_string_length + advice_string_length;
+
+	if (area == NULL)
+		ca = palloc(total_length);
+	else
+	{
+		*result = dsa_allocate(area, total_length);
+		ca = dsa_get_address(area, *result);
+	}
+
+	ca->userid = userid;
+	ca->dbid = dbid;
+	ca->queryid = queryId;
+	ca->timestamp = timestamp;
+	ca->advice_offset = query_string_length;
+
+	memcpy(ca->textual_data, query_string, query_string_length);
+	memcpy(&ca->textual_data[ca->advice_offset],
+		   advice_string, advice_string_length);
+
+	return ca;
+}
+
+/*
+ * Add a pgca_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+store_local_advice(pgca_collected_advice *ca)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_local_advice *la = local_collector;
+
+	/* If the local advice collector isn't initialized yet, do that now. */
+	if (la == NULL)
+	{
+		la = palloc0(sizeof(pgca_local_advice));
+		la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = palloc0_array(pgca_local_advice_chunk *,
+								   la->chunk_array_allocated_size);
+		local_collector = la;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Extend chunk array, if needed. */
+	if (chunk_number >= la->chunk_array_allocated_size)
+	{
+		int			new_size;
+
+		new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		la->chunks = repalloc0_array(la->chunks,
+									 pgca_local_advice_chunk *,
+									 la->chunk_array_allocated_size,
+									 new_size);
+		la->chunk_array_allocated_size = new_size;
+	}
+
+	/* Allocate new chunk, if needed. */
+	if (la->chunks[chunk_number] == NULL)
+		la->chunks[chunk_number] = palloc0_object(pgca_local_advice_chunk);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+	la->chunks[chunk_number]->entries[chunk_offset] = ca;
+	++la->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_local_advice(pg_collect_advice_local_collection_limit);
+}
+
+/*
+ * Add a pgca_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_collect_advice DSA area
+ * and should point to an object of type pgca_collected_advice.
+ */
+static void
+store_shared_advice(dsa_pointer ca_pointer)
+{
+	uint64		chunk_number;
+	uint64		chunk_offset;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	pgca_shared_advice *sa = shared_collector;
+	dsa_pointer *chunk_array;
+	pgca_shared_advice_chunk *chunk;
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now.
+	 * If we're the first ones to attach, we may need to create the object.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+			state->shared_collector =
+				dsa_allocate0(area, sizeof(pgca_shared_advice));
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/*
+	 * It's possible that some other backend may have succeeded in creating
+	 * the main collector object but failed to allocate an initial chunk
+	 * array, so we must be prepared to allocate the chunk array here whether
+	 * or not we created the collector object.
+	 */
+	if (shared_collector->chunk_array_allocated_size == 0)
+	{
+		sa->chunks =
+			dsa_allocate0(area,
+						  sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+		sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+	}
+
+	/* Compute chunk and offset at which to store this advice. */
+	chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+	/* Get the address of the chunk array and, if needed, extend it. */
+	if (chunk_number >= sa->chunk_array_allocated_size)
+	{
+		int			new_size;
+		dsa_pointer new_chunks;
+
+		/*
+		 * DSA can't enlarge an existing allocation, so we must make a new
+		 * allocation and copy data over.
+		 */
+		new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+		new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+		chunk_array = dsa_get_address(area, new_chunks);
+		memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+			   sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+		dsa_free(area, sa->chunks);
+		sa->chunks = new_chunks;
+		sa->chunk_array_allocated_size = new_size;
+	}
+	else
+		chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Get the address of the desired chunk, allocating it if needed. */
+	if (chunk_array[chunk_number] == InvalidDsaPointer)
+		chunk_array[chunk_number] =
+			dsa_allocate0(area, sizeof(pgca_shared_advice_chunk));
+	chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+	/* Save pointer and bump next-id counter. */
+	Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+	chunk->entries[chunk_offset] = ca_pointer;
+	++sa->next_id;
+
+	/* If we've exceeded the storage limit, discard old data. */
+	trim_shared_advice(area, pg_collect_advice_shared_collection_limit);
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+trim_local_advice(int limit)
+{
+	pgca_local_advice *la = local_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = la->next_id - la->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+
+		chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+		pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+		la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+		++la->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		pfree(la->chunks[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (la->next_id - la->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+				sizeof(pgca_local_advice_chunk *) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&la->chunks[remaining_chunk_count], 0,
+		   sizeof(pgca_local_advice_chunk *)
+		   * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+trim_shared_advice(dsa_area *area, int limit)
+{
+	pgca_shared_advice *sa = shared_collector;
+	uint64		current_count;
+	uint64		trim_count;
+	uint64		total_chunk_count;
+	uint64		trim_chunk_count;
+	uint64		remaining_chunk_count;
+	dsa_pointer *chunk_array;
+
+	/* If we haven't yet reached the limit, there's nothing to do. */
+	current_count = sa->next_id - sa->oldest_id;
+	if (current_count <= limit)
+		return;
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Free enough entries to get us back down to the limit. */
+	trim_count = current_count - limit;
+	while (trim_count > 0)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+
+		chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+		dsa_free(area, chunk->entries[chunk_offset]);
+		chunk->entries[chunk_offset] = InvalidDsaPointer;
+		++sa->oldest_id;
+		--trim_count;
+	}
+
+	/* Free any chunks that are now entirely unused. */
+	trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+	for (uint64 n = 0; n < trim_chunk_count; ++n)
+		dsa_free(area, chunk_array[n]);
+
+	/* Slide remaining chunk pointers back toward the base of the array. */
+	total_chunk_count = (sa->next_id - sa->base_id +
+						 ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+	remaining_chunk_count = total_chunk_count - trim_chunk_count;
+	if (remaining_chunk_count > 0)
+		memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+				sizeof(dsa_pointer) * remaining_chunk_count);
+
+	/* Don't leave stale pointers around. */
+	memset(&chunk_array[remaining_chunk_count], 0,
+		   sizeof(dsa_pointer) * (total_chunk_count - remaining_chunk_count));
+
+	/* Adjust base ID value accordingly. */
+	sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	if (local_collector != NULL)
+		trim_local_advice(0);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in shared memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+
+	LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (shared_collector == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* Do the real work */
+	trim_shared_advice(area, 0);
+
+	LWLockRelease(&state->lock);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_local_advice *la = local_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (la == NULL)
+		return (Datum) 0;
+
+	/* Loop over all entries. */
+	for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+		ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	pgca_shared_state *state = pg_collect_advice_attach();
+	dsa_area   *area = pg_collect_advice_dsa_area();
+	dsa_pointer *chunk_array;
+	pgca_shared_advice *sa = shared_collector;
+	Oid			userid = GetUserId();
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Lock the shared state. */
+	LWLockAcquire(&state->lock, LW_SHARED);
+
+	/*
+	 * If we're not attached to the shared advice collector yet, fix that now;
+	 * but if the collector doesn't even exist, we can return without doing
+	 * anything else.
+	 */
+	if (sa == NULL)
+	{
+		if (state->shared_collector == InvalidDsaPointer)
+		{
+			LWLockRelease(&state->lock);
+			return (Datum) 0;
+		}
+		shared_collector = sa = dsa_get_address(area, state->shared_collector);
+	}
+
+	/* If there's no chunk array yet, there's nothing to do. */
+	if (sa->chunks == InvalidDsaPointer)
+	{
+		LWLockRelease(&state->lock);
+		return (Datum) 0;
+	}
+
+	/* Get a pointer to the chunk array. */
+	chunk_array = dsa_get_address(area, sa->chunks);
+
+	/* Loop over all entries. */
+	for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+	{
+		uint64		chunk_number;
+		uint64		chunk_offset;
+		pgca_shared_advice_chunk *chunk;
+		pgca_collected_advice *ca;
+		Datum		values[PG_GET_ADVICE_COLUMNS];
+		bool		nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+		chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+		chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+		chunk = dsa_get_address(area, chunk_array[chunk_number]);
+		ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+		if (!member_can_set_role(userid, ca->userid))
+			continue;
+
+		values[0] = UInt64GetDatum(id);
+		values[1] = ObjectIdGetDatum(ca->userid);
+		values[2] = ObjectIdGetDatum(ca->dbid);
+		values[3] = UInt64GetDatum(ca->queryid);
+		values[4] = TimestampTzGetDatum(ca->timestamp);
+		values[5] = CStringGetTextDatum(query_string(ca));
+		values[6] = CStringGetTextDatum(advice_string(ca));
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	/* Release lock on shared state. */
+	LWLockRelease(&state->lock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pg_collect_advice/expected/local_collector.out b/contrib/pg_collect_advice/expected/local_collector.out
new file mode 100644
index 00000000000..f57b96ee835
--- /dev/null
+++ b/contrib/pg_collect_advice/expected/local_collector.out
@@ -0,0 +1,69 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b 
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b 
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+         advice         
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count 
+-------
+  2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice 
+---------------------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_collect_advice/interface.c b/contrib/pg_collect_advice/interface.c
new file mode 100644
index 00000000000..feb11974152
--- /dev/null
+++ b/contrib/pg_collect_advice/interface.c
@@ -0,0 +1,303 @@
+/*-------------------------------------------------------------------------
+ *
+ * interface.c
+ *	  interface routines for the plan advice collector
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/interface.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_collect_advice.h"
+
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+/* Shared memory pointers */
+static pgca_shared_state *pgca_state = NULL;
+static dsa_area *pgca_dsa_area = NULL;
+
+/* GUC variables */
+bool		pg_collect_advice_local_collector = false;
+int			pg_collect_advice_local_collection_limit = 0;
+bool		pg_collect_advice_shared_collector = false;
+int			pg_collect_advice_shared_collection_limit = 0;
+
+/* Shadow variables for GUC assign hooks */
+static bool pg_collect_advice_local_collector_as_assigned = false;
+static bool pg_collect_advice_shared_collector_as_assigned = false;
+
+/* Other file-level globals */
+static void (*request_advice_generation_fn) (bool activate) = NULL;
+static planner_shutdown_hook_type prev_planner_shutdown = NULL;
+static MemoryContext pgca_memory_context = NULL;
+
+/* Function prototypes */
+static void pgca_init_shared_state(void *ptr, void *arg);
+static void pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+								  const char *query_string,
+								  PlannedStmt *pstmt);
+static void pg_collect_advice_local_collector_assign_hook(bool newval,
+														  void *extra);
+static void pg_collect_advice_shared_collector_assign_hook(bool newval,
+														   void *extra);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	/*
+	 * Get a pointer so we can call pg_plan_advice_request_advice_generation.
+	 *
+	 * We need to do this before defining custom GUCs; otherwise, our assign
+	 * hook will try to use this function pointer before it's initialized.
+	 *
+	 * We also need to do this before installing our own hooks, so that if
+	 * pg_plan_advice is not yet loaded, it will install its hooks before we
+	 * install ours. (See comments in pgca_planner_shutdown.)
+	 */
+	request_advice_generation_fn =
+		load_external_function("pg_plan_advice",
+							   "pg_plan_advice_request_advice_generation",
+							   true, NULL);
+
+	/* Define our GUCs. */
+	DefineCustomBoolVariable("pg_collect_advice.local_collector",
+							 "Enable the local advice collector.",
+							 NULL,
+							 &pg_collect_advice_local_collector,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_local_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.local_collection_limit",
+							"# of advice entries to retain in per-backend memory",
+							NULL,
+							&pg_collect_advice_local_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_USERSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	DefineCustomBoolVariable("pg_collect_advice.shared_collector",
+							 "Enable the shared advice collector.",
+							 NULL,
+							 &pg_collect_advice_shared_collector,
+							 false,
+							 PGC_SUSET,
+							 0,
+							 NULL,
+							 pg_collect_advice_shared_collector_assign_hook,
+							 NULL);
+
+	DefineCustomIntVariable("pg_collect_advice.shared_collection_limit",
+							"# of advice entries to retain in shared memory",
+							NULL,
+							&pg_collect_advice_shared_collection_limit,
+							0,
+							0, INT_MAX,
+							PGC_SUSET,
+							0,
+							NULL,
+							NULL,
+							NULL);
+
+	MarkGUCPrefixReserved("pg_collect_advice");
+
+	/* Install hooks */
+	prev_planner_shutdown = planner_shutdown_hook;
+	planner_shutdown_hook = pgca_planner_shutdown;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgca_init_shared_state(void *ptr, void *arg)
+{
+	pgca_shared_state *state = (pgca_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_collect_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_collect_advice_dsa");
+	state->area = DSA_HANDLE_INVALID;
+	state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_collect_advice_get_mcxt(void)
+{
+	if (pgca_memory_context == NULL)
+		pgca_memory_context = AllocSetContextCreate(TopMemoryContext,
+													"pg_collect_advice",
+													ALLOCSET_DEFAULT_SIZES);
+
+	return pgca_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ */
+pgca_shared_state *
+pg_collect_advice_attach(void)
+{
+	if (pgca_state == NULL)
+	{
+		bool		found;
+
+		pgca_state =
+			GetNamedDSMSegment("pg_collect_advice", sizeof(pgca_shared_state),
+							   pgca_init_shared_state, &found, NULL);
+	}
+
+	return pgca_state;
+}
+
+/*
+ * Return a pointer to pg_collect_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_collect_advice_dsa_area(void)
+{
+	if (pgca_dsa_area == NULL)
+	{
+		pgca_shared_state *state = pg_collect_advice_attach();
+		dsa_handle	area_handle;
+		MemoryContext oldcontext;
+
+		oldcontext = MemoryContextSwitchTo(pg_collect_advice_get_mcxt());
+
+		LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+		area_handle = state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgca_dsa_area = dsa_create(state->dsa_tranche);
+			dsa_pin(pgca_dsa_area);
+			state->area = dsa_get_handle(pgca_dsa_area);
+			LWLockRelease(&state->lock);
+		}
+		else
+		{
+			LWLockRelease(&state->lock);
+			pgca_dsa_area = dsa_attach(area_handle);
+		}
+
+		dsa_pin_mapping(pgca_dsa_area);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	return pgca_dsa_area;
+}
+
+/*
+ * After planning is complete, retrieve the advice string, if present, and
+ * pass it through to the collector.
+ */
+static void
+pgca_planner_shutdown(PlannerGlobal *glob, Query *parse,
+					  const char *query_string, PlannedStmt *pstmt)
+{
+	DefElem    *pgpa_item;
+	DefElem    *advice_string_item;
+	char	   *advice_string;
+
+	/*
+	 * Pass call to previous hook.
+	 *
+	 * We want to be called after pg_plan_advice's shutdown hook has already
+	 * executed. Our _PG_init() makes sure that pg_plan_advice's hooks are
+	 * always loaded before ours, and here we pass the hook call down first,
+	 * before doing our own work. The combination of those two things should
+	 * be good enough to ensure that the advice string is already present when
+	 * we go looking for it.
+	 */
+	if (prev_planner_shutdown)
+		(*prev_planner_shutdown) (glob, parse, query_string, pstmt);
+
+	/* Fish out the advice string. If not found, do nothing. */
+	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
+										"pg_plan_advice");
+	if (pgpa_item == NULL)
+		return;
+	advice_string_item = find_defelem_by_defname((List *) pgpa_item->arg,
+												 "advice_string");
+	if (advice_string_item == NULL)
+		return;
+	advice_string = strVal(advice_string_item->arg);
+
+	/*
+	 * Pass it through to the actual collector. But, if it's the empty string,
+	 * we assume that collecting it is uninteresting.
+	 */
+	if (advice_string[0] != '\0')
+		pg_collect_advice_save(pstmt->queryId, query_string, advice_string);
+}
+
+/*
+ * pgca_planner_shutdown won't find any advice to collect unless we've
+ * requested that it be generated. So, whenever the effective value of
+ * pg_collect_advice.local_collector changes, either make or
+ * revoke a request for advice generation.
+ */
+static void
+pg_collect_advice_local_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_local_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_local_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_local_collector_as_assigned = newval;
+}
+
+/*
+ * Same as above, but for pg_collect_advice.shared_collector
+ */
+static void
+pg_collect_advice_shared_collector_assign_hook(bool newval, void *extra)
+{
+	if (pg_collect_advice_shared_collector_as_assigned && !newval)
+		(*request_advice_generation_fn) (false);
+	if (!pg_collect_advice_shared_collector_as_assigned && newval)
+		(*request_advice_generation_fn) (true);
+	pg_collect_advice_shared_collector_as_assigned = newval;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+	foreach_node(DefElem, item, deflist)
+	{
+		if (strcmp(item->defname, defname) == 0)
+			return item;
+	}
+
+	return NULL;
+}
diff --git a/contrib/pg_collect_advice/meson.build b/contrib/pg_collect_advice/meson.build
new file mode 100644
index 00000000000..102dc65d260
--- /dev/null
+++ b/contrib/pg_collect_advice/meson.build
@@ -0,0 +1,41 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_collect_advice_sources = files(
+  'collector.c',
+  'interface.c',
+)
+
+if host_system == 'windows'
+  pg_collect_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_collect_advice',
+    '--FILEDESC', 'pg_collect_advice - collect queries and their plan advice strings',])
+endif
+
+pg_collect_advice = shared_module('pg_collect_advice',
+  pg_collect_advice_sources,
+  include_directories: include_directories('.'),
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_collect_advice
+
+install_data(
+  'pg_collect_advice--1.0.sql',
+  'pg_collect_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_collect_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'local_collector',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_shared_collector.pl',
+    ],
+  },
+}
diff --git a/contrib/pg_collect_advice/pg_collect_advice--1.0.sql b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
new file mode 100644
index 00000000000..0be86c54fc1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_collect_advice/pg_collect_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_collect_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+	OUT id bigint,
+	OUT userid oid,
+	OUT dbid oid,
+	OUT queryid bigint,
+	OUT collection_time timestamptz,
+	OUT query text,
+	OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_clear_collected_shared_advice() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
diff --git a/contrib/pg_collect_advice/pg_collect_advice.control b/contrib/pg_collect_advice/pg_collect_advice.control
new file mode 100644
index 00000000000..601e5e24ea1
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.control
@@ -0,0 +1,5 @@
+# pg_collect_advice extension
+comment = 'collect queries and the associated plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_collect_advice'
+relocatable = true
diff --git a/contrib/pg_collect_advice/pg_collect_advice.h b/contrib/pg_collect_advice/pg_collect_advice.h
new file mode 100644
index 00000000000..480c2c633c4
--- /dev/null
+++ b/contrib/pg_collect_advice/pg_collect_advice.h
@@ -0,0 +1,39 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_collect_advice.h
+ *	  definitions and declarations for pg_collect_advice module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_collect_advice/pg_collect_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_COLLECT_ADVICE_H
+#define PG_COLLECT_ADVICE_H
+
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgca_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	dsa_handle	area;
+	dsa_pointer shared_collector;
+} pgca_shared_state;
+
+/* GUC variables */
+extern bool pg_collect_advice_local_collector;
+extern int	pg_collect_advice_local_collection_limit;
+extern bool pg_collect_advice_shared_collector;
+extern int	pg_collect_advice_shared_collection_limit;
+
+/* Function prototypes */
+extern MemoryContext pg_collect_advice_get_mcxt(void);
+extern pgca_shared_state *pg_collect_advice_attach(void);
+extern dsa_area *pg_collect_advice_dsa_area(void);
+extern void pg_collect_advice_save(uint64 queryId, const char *query_string,
+								   const char *advice_string);
+
+#endif
diff --git a/contrib/pg_collect_advice/sql/local_collector.sql b/contrib/pg_collect_advice/sql/local_collector.sql
new file mode 100644
index 00000000000..41b187c5375
--- /dev/null
+++ b/contrib/pg_collect_advice/sql/local_collector.sql
@@ -0,0 +1,46 @@
+CREATE EXTENSION pg_collect_advice;
+SET debug_parallel_query = off;
+
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_collect_advice.local_collection_limit = 2;
+
+-- Enable the collector.
+SET pg_collect_advice.local_collector = on;
+
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+	WITH (autovacuum_enabled = false, parallel_workers = 0);
+
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+SELECT * FROM dummy_table;
+
+-- Should return the advice from the second test query.
+SET pg_collect_advice.local_collector = off;
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id DESC LIMIT 1;
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_collect_advice.local_collection_limit = 2000;
+SET pg_collect_advice.local_collector = on;
+
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+	FOR x IN 1..2000 LOOP
+		EXECUTE 'SELECT * FROM dummy_table';
+	END LOOP;
+END
+$$;
+
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
diff --git a/contrib/pg_collect_advice/t/001_shared_collector.pl b/contrib/pg_collect_advice/t/001_shared_collector.pl
new file mode 100644
index 00000000000..bba0c883e5a
--- /dev/null
+++ b/contrib/pg_collect_advice/t/001_shared_collector.pl
@@ -0,0 +1,154 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Test the shared advice collector.
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Helper function, to avoid depending on exact line-break behavior.
+sub smash_whitespace
+{
+	my $s = shift;
+	$s =~ s/^\s+//;
+	$s =~ s/\s+$//;
+	$s =~ s/\s+/ /g;
+	return $s;
+}
+
+# Retrieve all collected shared advice as an array of whitespace-normalized
+# strings, ordered by id.
+sub get_collected_shared_advice
+{
+	my $psql = shift;
+	my $output = $psql->query_safe(
+		"SELECT string_agg(advice, '!SEPARATOR!' ORDER BY id) "
+		. "FROM pg_get_collected_shared_advice()");
+	return () if $output eq '';
+	return map { smash_whitespace($_) } split(/!SEPARATOR!/, $output);
+}
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+# Load pg_collect_advice and configure a shared collection limit of 5.
+$node->append_conf('postgresql.conf', <<EOM);
+shared_preload_libraries=pg_collect_advice
+pg_collect_advice.shared_collection_limit=5
+EOM
+$node->start;
+
+# Create the extension so we can access the collector
+my $test_db = 'collection_test';
+my $test_role = 'collection_role';
+$node->safe_psql('postgres', <<EOM);
+CREATE DATABASE $test_db;
+CREATE USER $test_role;
+ALTER ROLE $test_role SET pg_collect_advice.shared_collector = on;
+EOM
+$node->safe_psql($test_db, 'CREATE EXTENSION pg_collect_advice');
+
+# Set up two connections, one to control the testing process, and the other
+# to execute the queries under test.
+my $psql_control = $node->background_psql($test_db, on_error_stop => 1);
+my $psql_test =
+	$node->background_psql($test_db, on_error_stop => 1,
+						   extra_params => [ '--username' => $test_role ]);
+
+# Initial setup.
+$psql_control->query_safe(<<EOM);
+GRANT CREATE ON SCHEMA public TO $test_role;
+GRANT SET ON PARAMETER pg_collect_advice.shared_collection_limit TO $test_role;
+SET ROLE $test_role;
+CREATE TABLE sac_dim (id serial primary key, dim text)
+	WITH (autovacuum_enabled = false);
+INSERT INTO sac_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE sac_dim;
+
+CREATE TABLE sac_fact (
+	id int primary key,
+	dim_id integer not null references sac_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO sac_fact
+SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX sac_fact_dim_id ON sac_fact (dim_id);
+VACUUM ANALYZE sac_fact;
+RESET ROLE;
+EOM
+
+# Run a few test queries.
+$psql_test->query_safe(<<'EOM');
+SELECT * FROM sac_fact WHERE id = 42;
+SELECT * FROM sac_dim d JOIN sac_fact f ON d.id = f.dim_id;
+SELECT * FROM sac_dim d
+    WHERE d.id IN (SELECT f.dim_id FROM sac_fact f);
+EOM
+
+# Check that we got three advice collections, and the right values for each.
+my @advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 3, "three advice entries collected");
+is($advice[0], 'INDEX_SCAN(sac_fact public.sac_fact_pkey) NO_GATHER(sac_fact)',
+	"correct advice for query 1");
+is($advice[1], 'JOIN_ORDER(f d) HASH_JOIN(d) SEQ_SCAN(f d) NO_GATHER(d f)',
+	"correct advice for query 2");
+is($advice[2], 'JOIN_ORDER(d f) NESTED_LOOP_PLAIN(f) SEQ_SCAN(d) INDEX_ONLY_SCAN(f public.sac_fact_dim_id) SEMIJOIN_NON_UNIQUE(f) NO_GATHER(d f)',
+	"correct advice for query 3");
+
+# Run a few more test queries, overrunning the limit. (SET and PREPARE don't
+# trigger planning, but EXECUTE does.)
+$psql_test->query_safe(<<'EOM');
+BEGIN;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+SELECT count(*) FROM sac_fact;
+COMMIT;
+EXPLAIN SELECT * FROM sac_dim;
+PREPARE test_stmt AS SELECT * FROM sac_fact WHERE id = $1;
+EXECUTE test_stmt(42);
+EOM
+
+# Check that advice collection was trimmed to the configured limit.
+@advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 5, "advice trimmed to collection limit");
+
+# Check the advice for queries 4, 5, and 6.
+is($advice[2], 'SEQ_SCAN(sac_fact) GATHER(sac_fact)',
+	"correct advice for query 4");
+is($advice[3], 'SEQ_SCAN(sac_dim) NO_GATHER(sac_dim)',
+	"correct advice for query 5");
+is($advice[4],
+	'INDEX_SCAN(sac_fact public.sac_fact_pkey) NO_GATHER(sac_fact)',
+	"correct advice for query 6");
+
+# Raise the collection limit so that we can collect enough advice to need
+# multiple chunks, and then revert back to the old value, so that we try
+# to free an entire chunk.
+$psql_test->query_safe("SET pg_collect_advice.shared_collection_limit = 1500");
+$psql_test->query_safe(<<'EOM');
+DO $$
+BEGIN
+	FOR i IN 1..1500 LOOP
+		EXECUTE 'SELECT 1';
+	END LOOP;
+END $$;
+EOM
+@advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 1500, "increased collection limit reached");
+$psql_test->query_safe("RESET pg_collect_advice.shared_collection_limit");
+$psql_test->query_safe("SELECT * FROM sac_dim");
+@advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 5, "advice trimmed across chunk boundary");
+
+# Try clearing all the advice.
+$psql_control->query_safe("SELECT pg_clear_collected_shared_advice()");
+@advice = get_collected_shared_advice($psql_control);
+is(scalar @advice, 0, "all shared advice cleared");
+
+# Clean up.
+$psql_test->quit;
+$psql_control->quit;
+done_testing();
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index bdd4865f53f..2ab6fafbab1 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -152,6 +152,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pageinspect;
  &passwordcheck;
  &pgbuffercache;
+ &pgcollectadvice;
  &pgcrypto;
  &pgfreespacemap;
  &pglogicalinspect;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index d90b4338d2a..407ff3abffe 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -145,6 +145,7 @@
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
+<!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
 <!ENTITY pglogicalinspect  SYSTEM "pglogicalinspect.sgml">
diff --git a/doc/src/sgml/pgcollectadvice.sgml b/doc/src/sgml/pgcollectadvice.sgml
new file mode 100644
index 00000000000..220aabe78c6
--- /dev/null
+++ b/doc/src/sgml/pgcollectadvice.sgml
@@ -0,0 +1,244 @@
+<!-- doc/src/sgml/pgcollectadvice.sgml -->
+
+<sect1 id="pgcollectadvice" xreflabel="pg_collect_advice">
+ <title>pg_collect_advice &mdash; collect queries and their plan advice strings</title>
+
+ <indexterm zone="pgcollectadvice">
+  <primary>pg_collect_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_collect_advice</filename> extension allows you to
+  automatically generate plan advice each time a query is planned and store
+  the query and the generated advice string either in local or shared memory.
+  Note that this extension requires the <xref linkend="pgplanadvice" /> module,
+  which performs the actual plan advice generation; this module only knows
+  how to store the generated advice for later examination. Whenever
+  <literal>pg_collect_advice</literal> is loaded, it will automatically load
+  <literal>pg_plan_advice</literal>.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_collect_advice</literal> in at least
+  one database, so that you have a way to examine the collected advice.
+  You will also need the <literal>pg_collect_advice</literal> module
+  to be loaded in all sessions where advice is to be collected. It will
+  usually be best to do this by adding <literal>pg_collect_advice</literal>
+  to <xref linkend="guc-shared-preload-libraries"/> and restarting the
+  server.
+ </para>
+
+ <para>
+  <literal>pg_collect_advice</literal> includes both a shared advice
+  collector and a local advice collector. The local advice collector makes
+  queries and their advice strings visible only to the session where those
+  queries were planned, while the shared advice collector collects data
+  on a system-wide basis, and authorized users can examine data from all
+  sessions.
+ </para>
+
+ <para>
+  To enable a collector, you must first set a collection limit. When the
+  number of queries for which advice has been stored exceeds the collection
+  limit, the oldest queries and the corresponding advice will be discarded.
+  Then, you must adjust a separate setting to actually enable advice
+  collection. For the local collector, set the collection limit by configuring
+  <literal>pg_collect_advice.local_collection_limit</literal> to a value
+  greater than zero, and then enable advice collection by setting
+  <literal>pg_collect_advice.local_collector = true</literal>. For the shared
+  collector, the procedure is the same, except that the names of the settings
+  are <literal>pg_collect_advice.shared_collection_limit</literal> and
+  <literal>pg_collect_advice.shared_collector</literal>. Note that in both
+  cases, query texts and advice strings are stored in memory, so
+  configuring large limits may result in considerable memory consumption.
+ </para>
+
+ <para>
+  Once the collector is enabled, you can run any queries for which you wish
+  to see the generated plan advice. Then, you can examine what has been
+  collected using whichever of
+  <literal>SELECT * FROM pg_get_collected_local_advice()</literal> or
+  <literal>SELECT * FROM pg_get_collected_shared_advice()</literal>
+  corresponds to the collector you enabled. To discard the collected advice
+  and release memory, you can call
+  <literal>pg_clear_collected_local_advice()</literal>
+  or <literal>pg_clear_collected_shared_advice()</literal>.
+ </para>
+
+ <para>
+  In addition to the query texts and advice strings, the advice collectors
+  will also store the OID of the role that caused the query to be planned,
+  the OID of the database in which the query was planned, the query ID,
+  and the time at which the collection occurred. This module does not
+  automatically enable query ID computation; therefore, if you want the
+  query ID value to be populated in collected advice, be sure to configure
+  <literal>compute_query_id = on</literal>. Otherwise, the query ID may
+  always show as <literal>0</literal>.
+ </para>
+
+ <sect2 id="pgcollectadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_local_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from backend-local
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_local_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_local_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the local
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_clear_collected_shared_advice() returns void</function>
+     <indexterm>
+      <primary>pg_clear_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Removes all collected query texts and advice strings from shared
+      memory.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_collected_shared_advice() returns setof (id bigint,
+        userid oid, dbid oid, queryid bigint, collection_time timestamptz,
+        query text, advice text)</function>
+     <indexterm>
+      <primary>pg_get_collected_shared_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns all query texts and advice strings stored in the shared
+      advice collector.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collector</varname> enables the
+      local advice collector. The default value is <literal>false</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.local_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.local_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.local_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      local advice collector. The default value is <literal>0</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collector</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collector</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collector</varname> enables the
+      shared advice collector. The default value is <literal>false</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_collect_advice.shared_collection_limit</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_collect_advice.shared_collection_limit</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_collect_advice.shared_collection_limit</varname> sets the
+      maximum number of query texts and advice strings retained by the
+      shared advice collector. The default value is <literal>0</literal>.
+      Only superusers and users with the appropriate <literal>SET</literal>
+      privilege can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgcollectadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index decc9f7a572..d41dbbfa801 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4019,6 +4019,12 @@ pg_uuid_t
 pg_wchar
 pg_wchar_tbl
 pgp_armor_headers_state
+pgca_collected_advice
+pgca_local_advice
+pgca_local_advice_chunk
+pgca_shared_advice
+pgca_shared_advice_chunk
+pgca_shared_state
 pgpa_advice_item
 pgpa_advice_tag_type
 pgpa_advice_target
-- 
2.51.0



  [application/octet-stream] v23-0002-pg_plan_advice-Invent-DO_NOT_SCAN-relation_ident.patch (43.3K, 3-v23-0002-pg_plan_advice-Invent-DO_NOT_SCAN-relation_ident.patch)
  download | inline diff:
From 8a8914b9c489e8b7b72373c8fcea6e4d5b05f5a5 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
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



  [application/octet-stream] v23-0001-Add-an-alternative_plan_name-field-to-PlannerInf.patch (9.0K, 4-v23-0001-Add-an-alternative_plan_name-field-to-PlannerInf.patch)
  download | inline diff:
From 722000d42a267f4d20c2164bc9095ad00c3a8bc4 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Mon, 23 Mar 2026 08:49:44 -0400
Subject: [PATCH v23 1/4] Add an alternative_plan_name field to PlannerInfo.

Typically, we have only one PlannerInfo for any given subquery, but
when we are considering a MinMaxAggPath or a hashed subplan, we end
up creating a second PlannerInfo for the same portion of the query,
with a clone of the original range table. In fact, in the MinMaxAggPath
case, we might end up creating several clones, one per aggregate.

At present, there's no easy way for a plugin, such as pg_plan_advice,
to understand the relationships between the original range table and
the copies of it that are created in these cases.  To fix, add an
alternative_plan_name field to PlannerInfo. For a hashed subplan, this
is the plan name for the non-hashed alternative; for minmax aggregates,
this is the plan_name from the parent PlannerInfo; otherwise, it's the
same as plan_name.
---
 src/backend/optimizer/path/allpaths.c     |  2 +-
 src/backend/optimizer/plan/planagg.c      |  1 +
 src/backend/optimizer/plan/planner.c      | 15 +++++++++++----
 src/backend/optimizer/plan/subselect.c    |  6 +++---
 src/backend/optimizer/prep/prepjointree.c |  1 +
 src/backend/optimizer/prep/prepunion.c    |  2 +-
 src/include/nodes/pathnodes.h             | 12 ++++++++++++
 src/include/optimizer/planner.h           |  1 +
 8 files changed, 31 insertions(+), 9 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index c26f48edfa0..61093f222a1 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -2833,7 +2833,7 @@ set_subquery_pathlist(PlannerInfo *root, RelOptInfo *rel,
 	/* Generate a subroot and Paths for the subquery */
 	plan_name = choose_plan_name(root->glob, rte->eref->aliasname, false);
 	rel->subroot = subquery_planner(root->glob, subquery, plan_name,
-									root, false, tuple_fraction, NULL);
+									root, NULL, false, tuple_fraction, NULL);
 
 	/* Isolate the params needed by this specific subplan */
 	rel->subplan_params = root->plan_params;
diff --git a/src/backend/optimizer/plan/planagg.c b/src/backend/optimizer/plan/planagg.c
index 09b38b2c378..75f6475cb56 100644
--- a/src/backend/optimizer/plan/planagg.c
+++ b/src/backend/optimizer/plan/planagg.c
@@ -341,6 +341,7 @@ build_minmax_path(PlannerInfo *root, MinMaxAggInfo *mminfo,
 	subroot->query_level++;
 	subroot->parent_root = root;
 	subroot->plan_name = choose_plan_name(root->glob, "minmax", true);
+	subroot->alternative_plan_name = root->plan_name;
 
 	/* reset subplan-related stuff */
 	subroot->plan_params = NIL;
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 42604a0f75c..d19800ad6a5 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -515,8 +515,8 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
 							   &tuple_fraction, es);
 
 	/* primary planning entry point (may recurse for subqueries) */
-	root = subquery_planner(glob, parse, NULL, NULL, false, tuple_fraction,
-							NULL);
+	root = subquery_planner(glob, parse, NULL, NULL, NULL, false,
+							tuple_fraction, NULL);
 
 	/* Select best Path and turn it into a Plan */
 	final_rel = fetch_upper_rel(root, UPPERREL_FINAL, NULL);
@@ -715,6 +715,8 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
  * parse is the querytree produced by the parser & rewriter.
  * plan_name is the name to assign to this subplan (NULL at the top level).
  * parent_root is the immediate parent Query's info (NULL at the top level).
+ * alternative_root is a previously created PlannerInfo for which this query
+ * level is an alternative implementation, or else NULL.
  * hasRecursion is true if this is a recursive WITH query.
  * tuple_fraction is the fraction of tuples we expect will be retrieved.
  * tuple_fraction is interpreted as explained for grouping_planner, below.
@@ -741,8 +743,9 @@ standard_planner(Query *parse, const char *query_string, int cursorOptions,
  */
 PlannerInfo *
 subquery_planner(PlannerGlobal *glob, Query *parse, char *plan_name,
-				 PlannerInfo *parent_root, bool hasRecursion,
-				 double tuple_fraction, SetOperationStmt *setops)
+				 PlannerInfo *parent_root, PlannerInfo *alternative_root,
+				 bool hasRecursion, double tuple_fraction,
+				 SetOperationStmt *setops)
 {
 	PlannerInfo *root;
 	List	   *newWithCheckOptions;
@@ -758,6 +761,10 @@ subquery_planner(PlannerGlobal *glob, Query *parse, char *plan_name,
 	root->glob = glob;
 	root->query_level = parent_root ? parent_root->query_level + 1 : 1;
 	root->plan_name = plan_name;
+	if (alternative_root != NULL)
+		root->alternative_plan_name = alternative_root->plan_name;
+	else
+		root->alternative_plan_name = plan_name;
 	root->parent_root = parent_root;
 	root->plan_params = NIL;
 	root->outer_params = NULL;
diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c
index 0d31861da7f..ccec1eaa7fe 100644
--- a/src/backend/optimizer/plan/subselect.c
+++ b/src/backend/optimizer/plan/subselect.c
@@ -224,7 +224,7 @@ make_subplan(PlannerInfo *root, Query *orig_subquery,
 	/* Generate Paths for the subquery */
 	subroot = subquery_planner(root->glob, subquery,
 							   choose_plan_name(root->glob, sublinkstr, true),
-							   root, false, tuple_fraction, NULL);
+							   root, NULL, false, tuple_fraction, NULL);
 
 	/* Isolate the params needed by this specific subplan */
 	plan_params = root->plan_params;
@@ -274,7 +274,7 @@ make_subplan(PlannerInfo *root, Query *orig_subquery,
 			/* Generate Paths for the ANY subquery; we'll need all rows */
 			plan_name = choose_plan_name(root->glob, sublinkstr, true);
 			subroot = subquery_planner(root->glob, subquery, plan_name,
-									   root, false, 0.0, NULL);
+									   root, subroot, false, 0.0, NULL);
 
 			/* Isolate the params needed by this specific subplan */
 			plan_params = root->plan_params;
@@ -971,7 +971,7 @@ SS_process_ctes(PlannerInfo *root)
 		 */
 		subroot = subquery_planner(root->glob, subquery,
 								   choose_plan_name(root->glob, cte->ctename, false),
-								   root, cte->cterecursive, 0.0, NULL);
+								   root, NULL, cte->cterecursive, 0.0, NULL);
 
 		/*
 		 * Since the current query level doesn't yet contain any RTEs, it
diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c
index d5e1041ffa3..95bf51606cc 100644
--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -1418,6 +1418,7 @@ pull_up_simple_subquery(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte,
 	subroot->glob = root->glob;
 	subroot->query_level = root->query_level;
 	subroot->plan_name = root->plan_name;
+	subroot->alternative_plan_name = root->alternative_plan_name;
 	subroot->parent_root = root->parent_root;
 	subroot->plan_params = NIL;
 	subroot->outer_params = NULL;
diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index 583cb0b7a25..d1f022c5bfd 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -250,7 +250,7 @@ recurse_set_operations(Node *setOp, PlannerInfo *root,
 		 */
 		plan_name = choose_plan_name(root->glob, "setop", true);
 		subroot = rel->subroot = subquery_planner(root->glob, subquery,
-												  plan_name, root,
+												  plan_name, root, NULL,
 												  false, root->tuple_fraction,
 												  parentOp);
 
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 27758ec16fe..96ec3227262 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -320,6 +320,18 @@ struct PlannerInfo
 	/* Subplan name for EXPLAIN and debugging purposes (NULL at top level) */
 	char	   *plan_name;
 
+	/*
+	 * If this PlannerInfo exists to consider an alternative implementation
+	 * strategy for a portion of the query that could also be implemented by
+	 * some other PlannerInfo, this is the plan_name for that other
+	 * PlannerInfo. When we are considering the first or only alternative,
+	 * it is the same as plan_name.
+	 *
+	 * Currently, we set this to a value other than plan_name only when
+	 * considering a MinMaxAggPath or a hashed SubPlan.
+	 */
+	char	   *alternative_plan_name;
+
 	/*
 	 * plan_params contains the expressions that this query level needs to
 	 * make available to a lower query level that is currently being planned.
diff --git a/src/include/optimizer/planner.h b/src/include/optimizer/planner.h
index 80509773c01..9c4950b340f 100644
--- a/src/include/optimizer/planner.h
+++ b/src/include/optimizer/planner.h
@@ -63,6 +63,7 @@ extern PlannedStmt *standard_planner(Query *parse, const char *query_string,
 extern PlannerInfo *subquery_planner(PlannerGlobal *glob, Query *parse,
 									 char *plan_name,
 									 PlannerInfo *parent_root,
+									 PlannerInfo *alternative_root,
 									 bool hasRecursion, double tuple_fraction,
 									 SetOperationStmt *setops);
 
-- 
2.51.0



  [application/octet-stream] v23-0004-Add-pg_stash_advice-contrib-module.patch (58.7K, 5-v23-0004-Add-pg_stash_advice-contrib-module.patch)
  download | inline diff:
From d2b76b70a7dd4bd887e09227e733414cf13f59ff Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 27 Feb 2026 16:58:14 -0500
Subject: [PATCH v23 4/4] Add pg_stash_advice contrib module.

This module allows plan advice strings to be provided automatically
from an in-memory advice stash. Advice stashes are stored in dynamic
shared memory and must be recreated and repopulated after a server
restart. If pg_stash_advice.stash_name is set to the name of an advice
stash, and if query identifiers are enabled, the query identifier
for each query will be looked up in the advice stash and the
associated advice string, if any, will be used each time that query
is planned.
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_stash_advice/Makefile              |  26 +
 .../expected/pg_stash_advice.out              | 328 +++++++
 contrib/pg_stash_advice/meson.build           |  35 +
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |  43 +
 contrib/pg_stash_advice/pg_stash_advice.c     | 900 ++++++++++++++++++
 .../pg_stash_advice/pg_stash_advice.control   |   5 +
 .../pg_stash_advice/sql/pg_stash_advice.sql   | 147 +++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgstashadvice.sgml               | 218 +++++
 src/tools/pgindent/typedefs.list              |   6 +
 13 files changed, 1712 insertions(+)
 create mode 100644 contrib/pg_stash_advice/Makefile
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice.out
 create mode 100644 contrib/pg_stash_advice/meson.build
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice--1.0.sql
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.c
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.control
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice.sql
 create mode 100644 doc/src/sgml/pgstashadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 22071034e51..06615e123f0 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -37,6 +37,7 @@ SUBDIRS = \
 		pg_overexplain \
 		pg_plan_advice \
 		pg_prewarm	\
+		pg_stash_advice	\
 		pg_stat_statements \
 		pg_surgery	\
 		pg_trgm		\
diff --git a/contrib/meson.build b/contrib/meson.build
index ff422d9b7fc..4862ba97ed1 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -52,6 +52,7 @@ subdir('pg_overexplain')
 subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
+subdir('pg_stash_advice')
 subdir('pg_stat_statements')
 subdir('pgstattuple')
 subdir('pg_surgery')
diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
new file mode 100644
index 00000000000..cd9b7f30115
--- /dev/null
+++ b/contrib/pg_stash_advice/Makefile
@@ -0,0 +1,26 @@
+# contrib/pg_stash_advice/Makefile
+
+MODULE_big = pg_stash_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_stash_advice.o
+
+EXTENSION = pg_stash_advice
+DATA = pg_stash_advice--1.0.sql
+PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
+
+REGRESS = pg_stash_advice
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+ifdef USE_PGXS
+PG_CPPFLAGS = -I$(includedir_server)/extension
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice
+subdir = contrib/pg_stash_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
new file mode 100644
index 00000000000..d1e93579d8a
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -0,0 +1,328 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
+(13 rows)
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+(13 rows)
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           2
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+ stash_name | advice_string 
+------------+---------------
+(0 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+ERROR:  advice stash "no_such_stash" does not exist
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           1
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   | advice_string 
+---------------+---------------
+ regress_stash | SEQ_SCAN(d1)
+(1 row)
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+ERROR:  advice stash "regress_stash" already exists
+SELECT pg_drop_advice_stash('no_such_stash');
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+ERROR:  advice stash "no_such_stash" does not exist
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+ERROR:  cannot set advice string for query ID 0
+-- Stash names must be non-empty, ASCII, and not too long.
+SELECT pg_create_advice_stash('');
+ERROR:  advice stash name may not be zero length
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+ERROR:  advice stash names may not be longer than 63 bytes
+SELECT pg_create_advice_stash(E'caf\u00e9');
+ERROR:  advice stash name must not contain non-ASCII characters
+SET pg_stash_advice.stash_name = 'café';
+ERROR:  invalid value for parameter "pg_stash_advice.stash_name": "café"
+DETAIL:  advice stash name must not contain non-ASCII characters
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
+SELECT pg_drop_advice_stash('regress_empty_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
new file mode 100644
index 00000000000..b666bcd0f1b
--- /dev/null
+++ b/contrib/pg_stash_advice/meson.build
@@ -0,0 +1,35 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_stash_advice_sources = files(
+  'pg_stash_advice.c'
+)
+
+if host_system == 'windows'
+  pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_stash_advice',
+    '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',])
+endif
+
+pg_stash_advice = shared_module('pg_stash_advice',
+  pg_stash_advice_sources,
+  include_directories: [pg_plan_advice_inc, include_directories('.')],
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_stash_advice
+
+install_data(
+  'pg_stash_advice--1.0.sql',
+  'pg_stash_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_stash_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pg_stash_advice',
+    ],
+  },
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
new file mode 100644
index 00000000000..88dedd8ef1b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit
+
+CREATE FUNCTION pg_create_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_create_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_drop_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_drop_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint,
+									  advice_string text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_set_stashed_advice'
+LANGUAGE C;
+
+CREATE FUNCTION pg_get_advice_stashes(
+	OUT stash_name text,
+	OUT num_entries bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stashes'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_advice_stash_contents(
+	INOUT stash_name text,
+	OUT query_id bigint,
+	OUT advice_string text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
+LANGUAGE C;
+
+REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
new file mode 100644
index 00000000000..22122236694
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -0,0 +1,900 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.c
+ *	  Apply plan advice automatically, without SQL modifications.
+ *
+ * This module allows plan advice strings (as used and generated by
+ * pg_plan_advice) to be "stashed" in dynamic shared memory and, from
+ * there, automatically be applied to queries as they are planned.
+ * You can create any number of advice stashes, each of which is
+ * identified by a human-readable, ASCII name, and each of them is
+ * essentially a query ID -> advice_string mapping.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "common/string.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "lib/dshash.h"
+#include "nodes/queryjumble.h"
+#include "pg_plan_advice.h"
+#include "storage/dsm_registry.h"
+#include "storage/lwlock.h"
+#include "utils/builtins.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+#include "utils/tuplestore.h"
+
+PG_MODULE_MAGIC;
+
+PG_FUNCTION_INFO_V1(pg_create_advice_stash);
+PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
+PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
+PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
+PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+
+typedef struct pgsa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	int			stash_tranche;
+	int			entry_tranche;
+	uint64		next_stash_id;
+	dsa_handle	area;
+	dshash_table_handle stash_hash;
+	dshash_table_handle entry_hash;
+} pgsa_shared_state;
+
+typedef struct pgsa_stash
+{
+	char		name[NAMEDATALEN];
+	uint64		pgsa_stash_id;
+} pgsa_stash;
+
+typedef struct pgsa_entry_key
+{
+	uint64		pgsa_stash_id;
+	int64		queryId;
+} pgsa_entry_key;
+
+typedef struct pgsa_entry
+{
+	pgsa_entry_key key;
+	dsa_pointer advice_string;
+} pgsa_entry;
+
+typedef struct pgsa_stash_count
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	int64		num_entries;
+} pgsa_stash_count;
+
+#define SH_PREFIX pgsa_stash_count_table
+#define SH_ELEMENT_TYPE pgsa_stash_count
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct pgsa_stash_name
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	char	   *name;
+} pgsa_stash_name;
+
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/* Shared memory pointers */
+static pgsa_shared_state *pgsa_state;
+static dsa_area *pgsa_dsa_area;
+static dshash_table *pgsa_stash_dshash;
+static dshash_table *pgsa_entry_dshash;
+
+/* Shared memory hash table parameters */
+static dshash_parameters pgsa_stash_dshash_parameters = {
+	NAMEDATALEN,
+	sizeof(pgsa_stash),
+	dshash_strcmp,
+	dshash_strhash,
+	dshash_strcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+static dshash_parameters pgsa_entry_dshash_parameters = {
+	sizeof(pgsa_entry_key),
+	sizeof(pgsa_entry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+/* GUC variable */
+static char *pg_stash_advice_stash_name = "";
+
+/* Other global variables */
+static MemoryContext pg_stash_advice_mcxt;
+
+/* Function prototypes */
+static char *pgsa_advisor(PlannerGlobal *glob,
+						  Query *parse,
+						  const char *query_string,
+						  int cursorOptions,
+						  ExplainState *es);
+static void pgsa_attach(void);
+static void pgsa_check_stash_name(char *stash_name);
+static bool pgsa_check_stash_name_guc(char **newval, void **extra,
+									  GucSource source);
+static void pgsa_clear_advice_string(char *stash_name, int64 queryId);
+static void pgsa_create_stash(char *stash_name);
+static void pgsa_drop_stash(char *stash_name);
+static void pgsa_init_shared_state(void *ptr, void *arg);
+static uint64 pgsa_lookup_stash_id(char *stash_name);
+static void pgsa_set_advice_string(char *stash_name, int64 queryId,
+								   char *advice_string);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/* If compute_query_id = 'auto', we would like query IDs. */
+	EnableQueryId();
+
+	/* Define our GUCs. */
+	DefineCustomStringVariable("pg_stash_advice.stash_name",
+							   "Name of the advice stash to be used in this session.",
+							   NULL,
+							   &pg_stash_advice_stash_name,
+							   "",
+							   PGC_USERSET,
+							   0,
+							   pgsa_check_stash_name_guc,
+							   NULL,
+							   NULL);
+
+	MarkGUCPrefixReserved("pg_stash_advice");
+
+	/* Tell pg_plan_advice that we want to provide advice strings. */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+	(*add_advisor_fn) (pgsa_advisor);
+}
+
+/*
+ * SQL-callable function to create an advice stash
+ */
+Datum
+pg_create_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_create_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to drop an advice stash
+ */
+Datum
+pg_drop_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_drop_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to provide a list of advice stashes
+ */
+Datum
+pg_get_advice_stashes(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	pgsa_stash_count_table_hash *chash;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Tally up the number of entries per stash. */
+	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		pgsa_stash_count *c;
+		bool		found;
+
+		c = pgsa_stash_count_table_insert(chash,
+										  entry->key.pgsa_stash_id,
+										  &found);
+		if (!found)
+			c->num_entries = 1;
+		else
+			c->num_entries++;
+	}
+	dshash_seq_term(&iterator);
+
+	/* Emit results. */
+	dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[2];
+		bool		nulls[2];
+		pgsa_stash_count *c;
+
+		values[0] = CStringGetTextDatum(stash->name);
+		nulls[0] = false;
+
+		c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id);
+		values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries);
+		nulls[1] = false;
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to provide advice stash contents
+ */
+Datum
+pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	char	   *stash_name = NULL;
+	pgsa_stash_name_table_hash *nhash = NULL;
+	uint64		stash_id = 0;
+	pgsa_entry *entry;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* User can pass NULL for all stashes, or the name of a specific stash. */
+	if (!PG_ARGISNULL(0))
+	{
+		stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		pgsa_check_stash_name(stash_name);
+		stash_id = pgsa_lookup_stash_id(stash_name);
+
+		/* If the user specified a stash name, it should exist. */
+		if (stash_id == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("advice stash \"%s\" does not exist", stash_name));
+	}
+	else
+	{
+		pgsa_stash *stash;
+
+		/*
+		 * If we're dumping data about all stashes, we need an ID->name lookup
+		 * table.
+		 */
+		nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+		dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+		while ((stash = dshash_seq_next(&iterator)) != NULL)
+		{
+			pgsa_stash_name *n;
+			bool		found;
+
+			n = pgsa_stash_name_table_insert(nhash,
+											 stash->pgsa_stash_id,
+											 &found);
+			Assert(!found);
+			n->name = pstrdup(stash->name);
+		}
+		dshash_seq_term(&iterator);
+	}
+
+	/* Now iterate over all the entries. */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, false);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		char	   *this_stash_name;
+		char	   *advice_string;
+
+		/* Skip incomplete entries where the advice string was never set. */
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		if (stash_id != 0)
+		{
+			/*
+			 * We're only dumping data for one particular stash, so skip
+			 * entries for any other stash and use the stash name specified by
+			 * the user.
+			 */
+			if (stash_id != entry->key.pgsa_stash_id)
+				continue;
+			this_stash_name = stash_name;
+		}
+		else
+		{
+			pgsa_stash_name *n;
+
+			/*
+			 * We're dumping data for all stashes, so look up the correct name
+			 * to use in the hash table. If nothing is found, which is
+			 * possible due to race conditions, make up a string to use.
+			 */
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n != NULL)
+				this_stash_name = n->name;
+			else
+				this_stash_name = psprintf("<stash %" PRIu64 ">",
+										   entry->key.pgsa_stash_id);
+		}
+
+		/* Work out tuple values. */
+		values[0] = CStringGetTextDatum(this_stash_name);
+		nulls[0] = false;
+		values[1] = Int64GetDatum(entry->key.queryId);
+		nulls[1] = false;
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+		values[2] = CStringGetTextDatum(advice_string);
+		nulls[2] = false;
+
+		/* Emit the tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to update an advice stash entry for a particular
+ * query ID
+ *
+ * If the second argument is NULL, we delete any existing advice stash
+ * entry; otherwise, we either create an entry or update it with the new
+ * advice string.
+ */
+Datum
+pg_set_stashed_advice(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name;
+	int64		queryId;
+
+	if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+		PG_RETURN_NULL();
+
+	/* Get and check advice stash name. */
+	stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	pgsa_check_stash_name(stash_name);
+
+	/*
+	 * Get and check query ID.
+	 *
+	 * queryID 0 means no query ID was computed, so reject that.
+	 */
+	queryId = PG_GETARG_INT64(1);
+	if (queryId == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("cannot set advice string for query ID 0"));
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Now call the appropriate function to do the real work. */
+	if (PG_ARGISNULL(2))
+		pgsa_clear_advice_string(stash_name, queryId);
+	else
+	{
+		char	   *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+		pgsa_set_advice_string(stash_name, queryId, advice_string);
+	}
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * Get the advice string that has been configured for this query, if any,
+ * and return it. Otherwise, return NULL.
+ */
+static char *
+pgsa_advisor(PlannerGlobal *glob, Query *parse,
+			 const char *query_string, int cursorOptions,
+			 ExplainState *es)
+{
+	pgsa_entry_key key;
+	pgsa_entry *entry;
+	char	   *advice_string;
+	uint64		stash_id;
+
+	/*
+	 * Exit quickly if the stash name is empty or there's no query ID.
+	 */
+	if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0)
+		return NULL;
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/*
+	 * Translate pg_stash_advice.stash_name to an integer ID.
+	 *
+	 * pgsa_check_stash_name_guc() has already validated the advice stash
+	 * name, so we don't need to call pgsa_check_stash_name() here.
+	 */
+	stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name);
+	if (stash_id == 0)
+		return NULL;
+
+	/*
+	 * Look up the advice string for the given stash ID + query ID.
+	 *
+	 * If we find an advice string, we copy it into the current memory
+	 * context, presumably short-lived, so that we can release the lock on the
+	 * dshash entry. pg_plan_advice only needs the value to remain allocated
+	 * long enough for it to be parsed, so this should be good enough.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = parse->queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, false);
+	if (entry == NULL)
+		return NULL;
+	if (entry->advice_string == InvalidDsaPointer)
+		advice_string = NULL;
+	else
+		advice_string = pstrdup(dsa_get_address(pgsa_dsa_area,
+												entry->advice_string));
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we found an advice string, emit a debug message. */
+	if (advice_string != NULL)
+		elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s",
+			 pg_stash_advice_stash_name, key.queryId, advice_string);
+
+	return advice_string;
+}
+
+/*
+ * Attach to various structures in dynamic shared memory.
+ *
+ * This function is designed to be resilient against errors. That is, if it
+ * fails partway through, it should be possible to call it again, repeat no
+ * work already completed, and potentially succeed or at least get further if
+ * whatever caused the previous failure has been corrected.
+ */
+static void
+pgsa_attach(void)
+{
+	bool		found;
+	MemoryContext oldcontext;
+
+	/*
+	 * Create a memory context to make sure that any control structures
+	 * allocated in local memory are sufficiently persistent.
+	 */
+	if (pg_stash_advice_mcxt == NULL)
+		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
+													 "pg_stash_advice",
+													 ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
+
+	/* Attach to the fixed-size state object if not already done. */
+	if (pgsa_state == NULL)
+		pgsa_state = GetNamedDSMSegment("pg_stash_advice",
+										sizeof(pgsa_shared_state),
+										pgsa_init_shared_state,
+										&found, NULL);
+
+	/* Attach to the DSA area if not already done. */
+	if (pgsa_dsa_area == NULL)
+	{
+		dsa_handle	area_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		area_handle = pgsa_state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
+			dsa_pin(pgsa_dsa_area);
+			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_dsa_area = dsa_attach(area_handle);
+		}
+		dsa_pin_mapping(pgsa_dsa_area);
+	}
+
+	/* Attach to the stash_name->stash_id hash table if not already done. */
+	if (pgsa_stash_dshash == NULL)
+	{
+		dshash_table_handle stash_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
+		stash_handle = pgsa_state->stash_hash;
+		if (stash_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  NULL);
+			pgsa_state->stash_hash =
+				dshash_get_hash_table_handle(pgsa_stash_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  stash_handle, NULL);
+		}
+	}
+
+	/* Attach to the entry hash table if not already done. */
+	if (pgsa_entry_dshash == NULL)
+	{
+		dshash_table_handle entry_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
+		entry_handle = pgsa_state->entry_hash;
+		if (entry_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  NULL);
+			pgsa_state->entry_hash =
+				dshash_get_hash_table_handle(pgsa_entry_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  entry_handle, NULL);
+		}
+	}
+
+	/* Restore previous memory context. */
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Check whether an advice stash name is legal, and signal an error if not.
+ *
+ * Keep this in sync with pgsa_check_stash_name_guc, below.
+ */
+static void
+pgsa_check_stash_name(char *stash_name)
+{
+	/* Reject empty advice stash name. */
+	if (stash_name[0] == '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name may not be zero length"));
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash names may not be longer than %d bytes",
+					   NAMEDATALEN - 1));
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must not contain non-ASCII characters"));
+}
+
+/*
+ * As above, but for the GUC check_hook. We allow the empty string here,
+ * though, as equivalent to disabling the feature.
+ */
+static bool
+pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source)
+{
+	char	   *stash_name = *newval;
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash names may not be longer than %d bytes",
+							NAMEDATALEN - 1);
+		return false;
+	}
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must not contain non-ASCII characters");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Create an advice stash.
+ */
+static void
+pgsa_create_stash(char *stash_name)
+{
+	pgsa_stash *stash;
+	bool		found;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Create a stash with this name, unless one already exists. */
+	stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found);
+	if (found)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" already exists", stash_name));
+	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+}
+
+/*
+ * Remove any stored advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_clear_advice_string(char *stash_name, int64 queryId)
+{
+	pgsa_entry *entry;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer old_dp;
+
+	/* Translate the stash name to an integer ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/*
+	 * Look for an existing entry, and free it. But, be sure to save the
+	 * pointer to the associated advice string, if any.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		old_dp = InvalidDsaPointer;
+	else
+	{
+		old_dp = entry->advice_string;
+		dshash_delete_entry(pgsa_entry_dshash, entry);
+	}
+
+	/* Now we free the advice string as well, if there was one. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
+
+/*
+ * Drop an advice stash.
+ */
+static void
+pgsa_drop_stash(char *stash_name)
+{
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	dshash_seq_status iterator;
+	uint64		stash_id;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove the entry for this advice stash. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, true);
+	if (stash == NULL)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+	stash_id = stash->pgsa_stash_id;
+	dshash_delete_entry(pgsa_stash_dshash, stash);
+
+	/*
+	 * Now remove all the entries. Since pgsa_state->lock must be held at
+	 * least in shared mode to insert entries into pgsa_entry_dshash, it
+	 * doesn't matter whether we do this before or after deleting the entry
+	 * from pgsa_stash_dshash.
+	 */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		if (stash_id == entry->key.pgsa_stash_id)
+		{
+			if (entry->advice_string != InvalidDsaPointer)
+				dsa_free(pgsa_dsa_area, entry->advice_string);
+			dshash_delete_current(&iterator);
+		}
+	}
+	dshash_seq_term(&iterator);
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgsa_init_shared_state(void *ptr, void *arg)
+{
+	pgsa_shared_state *state = (pgsa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_stash_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
+	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
+	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
+	state->next_stash_id = UINT64CONST(1);
+	state->area = DSA_HANDLE_INVALID;
+	state->stash_hash = DSHASH_HANDLE_INVALID;
+	state->entry_hash = DSHASH_HANDLE_INVALID;
+}
+
+/*
+ * Look up the integer ID that corresponds to the given stash name.
+ *
+ * Returns 0 if no such stash exists.
+ */
+static uint64
+pgsa_lookup_stash_id(char *stash_name)
+{
+	pgsa_stash *stash;
+	uint64		stash_id;
+
+	/* Search the shared hash table. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, false);
+	if (stash == NULL)
+		return 0;
+	stash_id = stash->pgsa_stash_id;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	return stash_id;
+}
+
+/*
+ * Store a new or updated advice string for the given advice stash and query ID.
+ */
+static void
+pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
+{
+	pgsa_entry *entry;
+	bool		found;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer new_dp;
+	dsa_pointer old_dp;
+
+	/*
+	 * The work we need to do in this function is basically simple, but the
+	 * danger of a server-lifespan DSA memory leak is very real. Acquiring a
+	 * lock here helps for two reasons.
+	 *
+	 * First, it holds off interrupts, so that we can't bail out of this code
+	 * after allocating DSA memory for the advice string and before storing
+	 * the resulting pointer somewhere that others can find it.
+	 *
+	 * Second, we need to avoid a race against pgsa_drop_stash(). That
+	 * function removes a stash_name->stash_id mapping and all the entries for
+	 * that stash_id. Without the lock, there's a race condition no matter
+	 * which of those things it does first, because as soon as we've looked up
+	 * the stash ID, that whole function can execute before we do the rest of
+	 * our work, which would result in us adding an entry for a stash that no
+	 * longer exists.
+	 */
+	LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+
+	/* Look up the stash ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/* Allocate space for the advice string. */
+	new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1);
+	strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string);
+
+	/* Attempt to insert an entry into the hash table. */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find_or_insert_extended(pgsa_entry_dshash, &key, &found,
+										   DSHASH_INSERT_NO_OOM);
+
+	/*
+	 * If it didn't work, bail out, being careful to free the shared memory
+	 * we've already allocated before throwing an error, since error cleanup
+	 * will not do so.
+	 */
+	if (entry == NULL)
+	{
+		dsa_free(pgsa_dsa_area, new_dp);
+		ereport(ERROR,
+				errcode(ERRCODE_OUT_OF_MEMORY),
+				errmsg("out of memory"),
+				errdetail("could not insert advice string into shared hash table"));
+	}
+
+	/* Update the entry and release the lock. */
+	old_dp = found ? entry->advice_string : InvalidDsaPointer;
+	entry->advice_string = new_dp;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/*
+	 * We're not safe from leaks yet!
+	 *
+	 * There's now a pointer to new_dp in the entry that we just updated, but
+	 * that means that there's no longer anything pointing to old_dp. Free it
+	 * first, and then we can release our last LWLock, allowing interrupts.
+	 */
+	if (DsaPointerIsValid(old_dp))
+		dsa_free(pgsa_dsa_area, old_dp);
+	LWLockRelease(&pgsa_state->lock);
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control
new file mode 100644
index 00000000000..4a0fff5c866
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.control
@@ -0,0 +1,5 @@
+# pg_stash_advice extension
+comment = 'store and automatically apply plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_stash_advice'
+relocatable = true
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
new file mode 100644
index 00000000000..3f6bfb83114
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
@@ -0,0 +1,147 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+SET pg_stash_advice.stash_name = 'regress_stash';
+
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('no_such_stash');
+
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+
+-- Stash names must be non-empty, ASCII, and not too long.
+SELECT pg_create_advice_stash('');
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+SELECT pg_create_advice_stash(E'caf\u00e9');
+SET pg_stash_advice.stash_name = 'café';
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('regress_empty_stash');
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index 2ab6fafbab1..8f09d728698 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -160,6 +160,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 407ff3abffe..8c14bab84e9 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -144,6 +144,7 @@
 <!ENTITY oid2name        SYSTEM "oid2name.sgml">
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
+<!ENTITY pgstashadvice   SYSTEM "pgstashadvice.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
 <!ENTITY pgcollectadvice SYSTEM "pgcollectadvice.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
new file mode 100644
index 00000000000..089fc66446f
--- /dev/null
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -0,0 +1,218 @@
+<!-- doc/src/sgml/pgstashadvice.sgml -->
+
+<sect1 id="pgstashadvice" xreflabel="pg_stash_advice">
+ <title>pg_stash_advice &mdash; store and automatically apply plan advice</title>
+
+ <indexterm zone="pgstashadvice">
+  <primary>pg_stash_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_stash_advice</filename> extension allows you to stash
+  <link linkend="pgplanadvice">plan advice</link> strings in dynamic
+  shared memory where they can be automatically applied. An
+  <literal>advice stash</literal> is a mapping from
+  <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
+  strings. Whenever a session is asked to plan a query whose query ID appears
+  in the relevant advice stash, the plan advice string is automatically applied
+  to guide planning. Note that advice stashes exist purely in memory. This
+  means both that it is important to be mindful of memory consumption when
+  deciding how much plan advice to stash, and also that advice stashes must
+  be recreated and repopulated whenever the server is restarted.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
+  one database, so that you have access to the SQL functions to manage
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
+  module to be loaded in all sessions where you want this module to
+  automatically apply advice. It will usually be best to do this by adding
+  <literal>pg_stash_advice</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> and restarting the server.
+ </para>
+
+ <para>
+  Once you have met the above criteria, you can create advice stashes
+  using the <literal>pg_create_advice_stash</literal> function described
+  below and set the plan advice for a given query ID in a given stash using
+  the <literal>pg_set_stashed_advice</literal> function. Then, you need
+  only configure <literal>pg_stash_advice.stash_name</literal> to point
+  to the chosen advice stash name. For some use cases, rather than setting
+  this on a system-wide basis, you may find it helpful to use
+  <literal>ALTER DATABASE ... SET</literal> or
+  <literal>ALTER ROLE ... SET</literal> to configure values that will apply
+  only to a database or only to a certain role. Likewise, it may sometimes
+  be better to set the stash name in a particular session using
+  <literal>SET</literal>.
+ </para>
+
+ <para>
+  Because <literal>pg_stash_advice</literal> works on the basis of query
+  identifiers, you will need to determine the query identifier for each query
+  whose plan you wish to control. You will also need to determine the advice
+  string that you wish to store for each query. One way to do this is to use
+  <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
+  show the query ID, and the <literal>PLAN_ADVICE</literal> option will
+  show plan advice. <xref linkend="pgcollectadvice" /> can be used to
+  obtain this information for an entire workload, although care must be
+  taken since it can use up a lot of memory very quickly. Query identifiers can
+  also be obtained through tools such as <xref linkend="pgstatstatements" />
+  or <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
+  will not provide plan advice strings. Note that
+  <xref linkend="guc-compute-query-id" /> must be enabled for query
+  identifiers to be computed; if set to <literal>auto</literal>, loading
+  <literal>pg_stash_advice</literal> will enable it automatically.
+ </para>
+
+ <para>
+  Generally, the fact that the planner is able to change query plans as
+  the underlying distribution of data changes is a feature, not a bug.
+  Moreover, applying plan advice can have a noticeable performance cost even
+  when it does not result in a change to the query plan. Therefore, it is
+  a good idea to use this feature only when and to the extent needed.
+  Plan advice strings can be trimmed down to mention only those aspects
+  of the plan that need to be controlled, and used only for queries where
+  there is believed to be a significant risk of planner error.
+ </para>
+
+ <para>
+  Note that <literal>pg_stash_advice</literal> currently lacks a sophisticated
+  security model. Only the superuser, or a user to whom the superuser has
+  granted <literal>EXECUTE</literal> permission on the relevant functions,
+  may create advice stashes or alter their contents, but any user may set
+  <literal>pg_stash_advice.stash_name</literal> for their session, and this
+  may reveal the contents of any advice stash with that name. Users should
+  assume that information embedded in stashed advice strings may become visible
+  to nonprivileged users.
+ </para>
+
+ <sect2 id="pgstashadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_create_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_create_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Creates a new, empty advice stash with the given name.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_drop_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_drop_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Drops the named advice stash and all of its entries.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_set_stashed_advice(stash_name text, query_id bigint,
+       advice_string text) returns void</function>
+     <indexterm>
+      <primary>pg_set_stashed_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Stores an advice string in the named advice stash, associated with
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
+      <parameter>advice_string</parameter> is <literal>NULL</literal>,
+      any existing entry for that query identifier is removed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stashes() returns setof (stash_name text,
+       num_entries bigint)</function>
+     <indexterm>
+      <primary>pg_get_advice_stashes</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each advice stash, showing the stash name and
+      the number of entries it contains.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stash_contents(stash_name text) returns setof
+       (stash_name text, query_id bigint, advice_string text)</function>
+     <indexterm>
+      <primary>pg_get_advice_stash_contents</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each entry in the named advice stash. If
+      <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
+      entries from all stashes.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.stash_name</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the name of the advice stash to consult during query
+      planning. The default value is the empty string, which disables
+      this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index d41dbbfa801..e8b972b397d 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4056,6 +4056,12 @@ pgpa_trove_lookup_type
 pgpa_trove_result
 pgpa_trove_slice
 pgpa_unrolled_join
+pgsa_entry
+pgsa_entry_key
+pgsa_shared_state
+pgsa_stash
+pgsa_stash_count
+pgsa_stash_name
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-26 19:51  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-03-26 19:51 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

Hi Robert,

On Thu, Mar 26, 2026 at 10:20 AM Robert Haas <[email protected]> wrote:
> > The dangling pointers are a good point; I agree that's bad. However,
> > I'd be more inclined to fix it by nulling out the alternative_root
> > pointers at the end of set_plan_references. I think that would just be
> > the case where root->isAltSubplan[ndx] && root->isUsedSubplan[ndx].
> > The reason I'm reluctant to just store the name is that there's not an
> > easy way to find a PlannerInfo by name. I originally proposed an
> > "allroots" list in PlannerGlobal, but we went with subplanNames on
> > Tom's suggestion. I subsequently realized that this kind of stinks for
> > code that is trying to use this infrastructure for anything, for
> > exactly this reason, but Tom never responded and I never pressed the
> > issue. But I think we're boxing ourselves into a corner if we just
> > keep storing names that can't be looked up everywhere. It doesn't
> > matter for the issue before us, so maybe doing as you say here is the
> > right idea just so we can move forward, but I think we're probably
> > kidding ourselves a little bit.
>
> Here's a new version, where I've replaced alternative_root by
> alternative_plan_name, serving the same function.

Great, I think that's better for now, and if we have a broader use
case in the future we can always adjust this to be the full
PlannerInfo.

That said, reflecting on the change, I wonder if its odd that we're
copying a string pointer instead of making an actual string copy. I
think that's probably okay in practice?

I'm still 50/50 on the naming here, since we have the alternative sub
plan that has an "alternative plan name" that's not that of the
alternative itself, but rather the base plan that was utilized. But I
see your concern regarding the naming being confusing in terms of what
the "original" or "base" would actually refer to. I've also considered
whether something like "alternative_plan_group" could make sense
(since all alternative sub plans will have the same value), but maybe
that conveys too much intent on what this is used for.

That said, I think for now, to get the buildfarm happy again, v23/0001
seems good.

v23/0002 also looks good.

Thanks,
Lukas

--
Lukas Fittl





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-26 23:20  Mark Dilger <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 1 reply; 133+ messages in thread

From: Mark Dilger @ 2026-03-26 23:20 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

Hi Robert,

Thanks for the pg_plan_advice patches!  I've tried hard to attack them, to
get
them to fail in some catastrophic way.  You seem to have hardened the code
well.  I found only one concern for you to consider, a kind of memory leak
ratchet:

Once the system reaches memory pressure where:
  - The 8192-byte DSA size class is exhausted (needs a new DSM segment, OS
refuses)
  - Smaller size classes still have free space in existing superblocks

Then every single query that triggers advice collection will:
  1. Successfully allocate an advice entry from existing free space
  2. Enter store_shared_advice, hit the same chunk boundary
  3. Fail to allocate the chunk
  4. Leak the advice entry
  5. Reduce remaining free space in the small size classes

This continues until the small size classes are also exhausted, at which
point
make_collected_advice itself fails (the DSA area has been consumed by leaked
entries). The ratchet is self-reinforcing: each failure guarantees the next
failure while consuming more resources, assuming nobody else is freeing
memory simultaneously.

I looked for situations where something inside postgres would keep retrying
after the OOM, but the most likely I think is just an application that
treats
OOM as a transient error and keeps retrying.

See the make_collected_advice() call in pg_collect_advice_save(); the point
where DSA memory is allocated but not yet linked into any data structure.
Everything downstream from here (the four dsa_allocate0 calls inside
store_shared_advice) can fail and leak it.

On Thu, Mar 26, 2026 at 10:20 AM Robert Haas <[email protected]> wrote:

> On Thu, Mar 26, 2026 at 9:55 AM Robert Haas <[email protected]> wrote:
> > Whoops. Obviously got the wrong thing stuck in my cut and paste buffer
> > when I was writing that. Thanks for checking it. I'm going to go ahead
> > and commit this, because I'm pretty confident that it's correct, and
> > the rest of these patches are not going to fix the buildfarm
> > instability without it, and I'm pretty sure multiple committers are
> > pretty tired of seeing these test_plan_advice failures already.
>
> Done.
>
> > Right, the comment isn't quite correct. I don't think your rewording
> > is quite right either, though, because there's really no reason to
> > mention plan_name here at all. I'll adjust it.
>
> Done and committed, after also adjusting the memory context handling
> to avoid re-breaking GEQO.
>
> > The dangling pointers are a good point; I agree that's bad. However,
> > I'd be more inclined to fix it by nulling out the alternative_root
> > pointers at the end of set_plan_references. I think that would just be
> > the case where root->isAltSubplan[ndx] && root->isUsedSubplan[ndx].
> > The reason I'm reluctant to just store the name is that there's not an
> > easy way to find a PlannerInfo by name. I originally proposed an
> > "allroots" list in PlannerGlobal, but we went with subplanNames on
> > Tom's suggestion. I subsequently realized that this kind of stinks for
> > code that is trying to use this infrastructure for anything, for
> > exactly this reason, but Tom never responded and I never pressed the
> > issue. But I think we're boxing ourselves into a corner if we just
> > keep storing names that can't be looked up everywhere. It doesn't
> > matter for the issue before us, so maybe doing as you say here is the
> > right idea just so we can move forward, but I think we're probably
> > kidding ourselves a little bit.
>
> Here's a new version, where I've replaced alternative_root by
> alternative_plan_name, serving the same function.
>
> --
> Robert Haas
> EDB: http://www.enterprisedb.com
>


-- 

*Mark Dilger*


^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-26 23:25  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-26 23:25 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 26, 2026 at 3:52 PM Lukas Fittl <[email protected]> wrote:
> That said, reflecting on the change, I wonder if its odd that we're
> copying a string pointer instead of making an actual string copy. I
> think that's probably okay in practice?

Normally, all of planning happens in the same memory context. Under
GEQO, joins are planned in shorter-lived contexts that are reset, but
I don't think a new PlannerInfo can get created in one of those
short-lived contexts. At any rate, there's no point in having two
copies of the same string in the same memory context.

> I'm still 50/50 on the naming here, since we have the alternative sub
> plan that has an "alternative plan name" that's not that of the
> alternative itself, but rather the base plan that was utilized. But I
> see your concern regarding the naming being confusing in terms of what
> the "original" or "base" would actually refer to. I've also considered
> whether something like "alternative_plan_group" could make sense
> (since all alternative sub plans will have the same value), but maybe
> that conveys too much intent on what this is used for.

Let's go with this for now, and if a consensus emerges that I got it
wrong, we can always change it.

> That said, I think for now, to get the buildfarm happy again, v23/0001
> seems good.
>
> v23/0002 also looks good.

Thanks. Committed. Nothing has obviously broken so far, BUT even
machines like skink that were failing weren't failing on every run, so
it may be a while before we get a clear view of the situation --
unless of course this didn't fix it or even made things worse, in
which case we might find out a lot faster. Hopefully not, but then I
thought this was going to work the first time.

In the meanwhile, we should try to make a decision on what to do about
pg_collect_advice and pg_stash_advice.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-27 08:00  Jakub Wartak <[email protected]>
  parent: Robert Haas <[email protected]>
  2 siblings, 2 replies; 133+ messages in thread

From: Jakub Wartak @ 2026-03-27 08:00 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 26, 2026 at 6:20 PM Robert Haas <[email protected]> wrote:
>[..v23]

0003: please be the judge here, as I'm not sure. Isn't there some too high
concurrency hit in pg_get_collected_shared_advice? If I do
pgbench -M extended -c 12 -j 12 -P 1 -S:

    progress: 59.0 s, 191008.4 tps, lat 0.063 ms stddev 0.200, 0 failed
    progress: 60.0 s, 197571.2 tps, lat 0.061 ms stddev 0.026, 0 failed
    progress: 61.0 s, 189825.5 tps, lat 0.063 ms stddev 0.208, 0 failed
    progress: 62.0 s, 197082.4 tps, lat 0.061 ms stddev 0.027, 0 failed
    progress: 63.0 s, 69345.9 tps, lat 0.173 ms stddev 1.651, 0 failed
    progress: 64.0 s, 47243.6 tps, lat 0.251 ms stddev 2.128, 0 failed
    progress: 65.0 s, 48211.6 tps, lat 0.247 ms stddev 2.156, 0 failed

there is visible collapse from 190k to 48k tps was due to constant flood
of artificial calls of: select count(*) from pg_get_collected_shared_advice();

The code does LW_SHARED there over potentially lots of of tuplestore_putvalues()
calls. However any other backend does pgca_planner_shutdown()->
pg_collect_advice_save()->store_shared_advice() which is trying to grab
LW_EXCLUSIVE lock, so everything might be be blocked across whole cluster? (I
mean for the duration of tuplestore entry and that seems to even talk about
"tape"/"disk", so to me it looks like prolonged I/O operations for temp might
impact CPU-only planning stuff?)

Maybe it is possible to buffer those reads under LW_SHARED into
backend-only (private)
memory and later just fill tuplestore later to avoid such hazard? (but the
obvious problem is how much memory we can have and how big shared area can
become). Or maybe after some time simply release it and sleep and re-take it?

0004: question, why in the pg_get_advice_stashes() the second call to
dshash_seq_init() nearby "Emit results" is done with exclusive=true , but
apparently only reads it?

-J.





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-27 08:31  Lukas Fittl <[email protected]>
  parent: Jakub Wartak <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-03-27 08:31 UTC (permalink / raw)
  To: Jakub Wartak <[email protected]>; Robert Haas <[email protected]>; +Cc: Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Mar 27, 2026 at 1:00 AM Jakub Wartak
<[email protected]> wrote:
>
> On Thu, Mar 26, 2026 at 6:20 PM Robert Haas <[email protected]> wrote:
> >[..v23]
>
> 0003: please be the judge here, as I'm not sure. Isn't there some too high
> concurrency hit in pg_get_collected_shared_advice? If I do

I've been thinking more about 0003 (pg_collect_advice) today, and I'm
getting increasingly skeptical that we should try to get that into 19.
It just feels like there is more design work to be done here, and I
don't see the pressing need to have this in place.

Instead, I wonder if we should just add a "debug_log_plan_advice"
setting to pg_plan_advice, that always logs the plan advice when
enabled. Basically like "always_store_advice_details", but emit a log
line in addition to doing the work. That could either be enabled on a
single session with a sufficiently high client_min_messages to consume
it directly, or written to the log for a few minutes when trying to
capture production activity (on small production systems where the
logging overhead is acceptable).

I don't see a log-based approach be less useful than the shared memory
approach, because I think our aggregation design here is not right yet
(and doesn't scale to production traffic), and so we might as well
have the community try out some things with the log output instead.

For the other one 0004 (pg_stash_advice) I feel its worth trying to
get it in, if we can figure out the remaining issues. I'll try to do
another pass on that tomorrow after some sleep.

Thanks,
Lukas

-- 
Lukas Fittl





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-27 12:55  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-27 12:55 UTC (permalink / raw)
  To: Alexander Lakhin <[email protected]>; +Cc: Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Mar 27, 2026 at 2:00 AM Alexander Lakhin <[email protected]> wrote:
> I could not reproduce recent failures from skink and morepork so far, but
> I found that changing some GUCs can trigger similar warnings, e.g.:

Thanks for the reports.

> echo "geqo_threshold = 8" >/tmp/extra.config

Failures here are expected. When planning is done via GEQO, not all
join orders are explored, and pg_plan_advice can only constrain the
join order from among the options considered by the planner. So, with
GEQO + test_plan_advice, any given test is going to pass if the second
round of planning considers the join order chosen by the first, and
fail otherwise.

This could be improved at some point in the future. For example,
somebody could add hooks to GEQO so that pg_plan_advice can cause it
to generate only candidates which are consistent with the supplied
advice. In practice, I'm not sure this is going to be a good use of
time. I suspect the energy would be better invested in improving GEQO
or coming up with a more useful replacement. The gap that exists here
doesn't mean that you can't use pg_plan_advice with GEQO; it only
means that you are going to have a bad time using them together if you
provide *complete* (or nearly-complete) plan advice.

> echo "join_collapse_limit = 1000" >/tmp/extra.config

The cause here actually seems to be GEQO once again. Raising the
join_collapse_limit causes some join problems to get bigger, which has
the result that they then use GEQO. At least for me, if I also bump up
geqo_threshold, the failures go away.

> and an assertion failure with:
> enable_parallel_append = off
> enable_partitionwise_aggregate = on
> cpu_tuple_cost = 1000
>
> TRAP: failed Assert("relids != NULL"), File: "pgpa_scan.c", Line: 248, PID: 1956762

Obviously, this one's a bug. I think the attached should fix it.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] 0001-pg_plan_advice-Avoid-assertion-failure-with-partitio.ncfbot (1.7K, 2-0001-pg_plan_advice-Avoid-assertion-failure-with-partitio.ncfbot)
  download

^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-27 13:08  Robert Haas <[email protected]>
  parent: Jakub Wartak <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-27 13:08 UTC (permalink / raw)
  To: Jakub Wartak <[email protected]>; +Cc: Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Mar 27, 2026 at 4:00 AM Jakub Wartak
<[email protected]> wrote:
> there is visible collapse from 190k to 48k tps was due to constant flood
> of artificial calls of: select count(*) from pg_get_collected_shared_advice();
>
> The code does LW_SHARED there over potentially lots of of tuplestore_putvalues()
> calls. However any other backend does pgca_planner_shutdown()->
> pg_collect_advice_save()->store_shared_advice() which is trying to grab
> LW_EXCLUSIVE lock, so everything might be be blocked across whole cluster? (I
> mean for the duration of tuplestore entry and that seems to even talk about
> "tape"/"disk", so to me it looks like prolonged I/O operations for temp might
> impact CPU-only planning stuff?)

Yeah ... I mean, I don't know what you want here.  If you fetch very
large quantities of data under a shared lock while concurrent activity
is trying to add data under an exclusive lock, that's going to be
slow. Now, as you say, there are ways to improve this. However, I
don't feel like running pg_get_collected_shared_advice() in a tight
loop is a normal use case. Normally you would turn it on, run a bunch
of queries, and then run that once at the end. Even that could hit
some issues because every session will be fighting to insert into the
hash table, but here you've made it much worse in a way that I would
say is artificial.

> 0004: question, why in the pg_get_advice_stashes() the second call to
> dshash_seq_init() nearby "Emit results" is done with exclusive=true , but
> apparently only reads it?

Good question. Actually, couldn't both of those loops use a shared lock only?

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-27 14:20  Robert Haas <[email protected]>
  parent: Mark Dilger <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-03-27 14:20 UTC (permalink / raw)
  To: Mark Dilger <[email protected]>; +Cc: Lukas Fittl <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Mar 26, 2026 at 7:21 PM Mark Dilger
<[email protected]> wrote:
> Then every single query that triggers advice collection will:
>   1. Successfully allocate an advice entry from existing free space
>   2. Enter store_shared_advice, hit the same chunk boundary
>   3. Fail to allocate the chunk
>   4. Leak the advice entry
>   5. Reduce remaining free space in the small size classes

Yeah, that's a leak. I just got through trying to harden
pg_stash_advice so that kind of thing can't happen, but I failed to
realize that pg_collect_advice has a version of the same issue.

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-27 15:44  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-27 15:44 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Mar 27, 2026 at 4:31 AM Lukas Fittl <[email protected]> wrote:
> I've been thinking more about 0003 (pg_collect_advice) today, and I'm
> getting increasingly skeptical that we should try to get that into 19.
> It just feels like there is more design work to be done here, and I
> don't see the pressing need to have this in place.
>
> Instead, I wonder if we should just add a "debug_log_plan_advice"
> setting to pg_plan_advice, that always logs the plan advice when
> enabled. Basically like "always_store_advice_details", but emit a log
> line in addition to doing the work. That could either be enabled on a
> single session with a sufficiently high client_min_messages to consume
> it directly, or written to the log for a few minutes when trying to
> capture production activity (on small production systems where the
> logging overhead is acceptable).

Sure, we could do something like that. It means that people have to do
log parsing to get the advice out cleanly, but it avoids the concerns
about memory utilization, and it's simple to code.

> For the other one 0004 (pg_stash_advice) I feel its worth trying to
> get it in, if we can figure out the remaining issues. I'll try to do
> another pass on that tomorrow after some sleep.

Let me just talk a little about the way that I see the space of
possible designs here. Let's set aside what we do or do not have time
to code for a moment and just think about what makes some kind of
sense in theory. I believe we can divide this up along a few different
axes:

1. Where is advice stored for on-the-fly retrieval? Possible answers
include: in shared memory, in local memory, in a table, on disk. But
realistically, I doubt that "in a table" is a realistic option. Even
if we hard-coded a direct index lookup i.e. without going through the
query planner, I think this would be a fairly expensive thing to do
for every query, and if this is going to be usable on production
systems, it has to be fast. I am absolutely certain that "on disk" is
not a realistic option. Local memory is an option. It has the
advantage of making server-lifespan memory leaks impossible, and the
further advantage of avoiding contention between different backends,
since each backend would have its own copy of the data. The big
disadvantage is memory consumption. If we think the advice store is
going to be small, then that might be fine, but somebody is
potentially going to have thousands of advice strings stored,
duplicating that across hundreds (or, gulp, thousands) of backends is
pretty bad.

2. What provision do we have for durability? Possible answers include:
in a table, on disk, nothing. I went with nothing, partly on the
theory that it gives users more flexibility. We don't really care
where they store their query IDs and advice strings, as long as they
have a way to feed them into the mechanism. But of course I was also
trying to save myself implementation complexity. There are some tricky
things about a table: as implemented, the advice stores are
cluster-wide objects, but tables are database objects. If we're
supposed to automatically load advice strings from a table that might
be in another database into an in-memory store, well, we can't. That
could be fixed by scoping stores to a specific database, which would
be inconvenient only for users who have the same schema in multiple
databases and would want to share stashes across DBs, which is
probably not common. A disk file is also an option. It requires
inventing some kind of custom format that we can generate and parse,
which has some complexity, but reading from a table has some
complexity too; I'm not sure which is messier.

3. How do we limit memory usage? One possible approach involves
limiting the size of the hash table by entries or by memory
consumption, but I don't think that's too valuable if that's all we
do, because presumably all that's going to do is annoy people who hit
the limit. It could make sense if we switched to a design where the
superuser creates the stash, assigns it a memory limit, and then
there's a way to give permission to use that stash to some particular
user who is bound by the memory limit. In that kind of design, the
person setting aside the memory has different and superior privileges
to the person using it, so the cap serves a real purpose. A
complicating factor here is that dshash doesn't seem to be well set up
to enforce memory limits. You can do it if each dshash uses a separate
DSA, but that's clunky, too. A completely different direction is to
treat the in-memory component of the system as a cache, backed by a
table or file from which cold entries are retrieved as needed. The
problem with this - and I think it's a pretty big problem - is that
performance will probably fall off a cliff as soon as you overflow the
cache. I mean, it might not, if most of the requests can be satisfied
from cache and a small percentage get fetched from cold storage, but
if it's a case where a uniformly-accessed working set is 10% larger
than the cache, the cache hit ratio will go down the tubes and so will
performance.

4. How do we match advice strings to queries? The query ID is the
obvious answer, but you could also think of having an option to match
on query text, for cases when query IDs collide. You could do
something like store a tag in the query string or in a GUC and look
that up, instead of the query ID, but then I'm not sure why you don't
just store the advice string instead of the tag, and then you don't
need a hash table lookup anymore. You could do some kind of pattern
matching on the query string rather than using the query ID, but that
feels like it would be hard to get right, and also probably more
expensive. There are probably other options here but I don't know what
they are. I doubt that it makes sense from a performance standpoint to
delegate out to a function written in a high-level language, and if
you want to delegate to C code then you can just forget about
pg_stash_advice and just use the add_advisor hook directly. I really
feel like I'm probably missing some possible techniques, here. I
wonder if other people will come up with some clever ideas.

5. What should the security model be? Right now it's as simple as can
be: the superuser gets to decide who can use the mechanism. But also,
not to be overlooked, an individual session can always opt out of
automatic advice application by clearing the stash_name GUC. It
shouldn't be possible to force wrong answers on another session even
if you can impose arbitrary plan_advice, but there is a good chance
that you could find a way to make their session catastrophically slow,
so it's good that users can opt themselves out of the mechanism.
Nonetheless, if we really want to have mutually untrusting users be
able to use this facility, then stashes should have owners, and you
should only be able to access a stash whose owner has authorized you
to do so. This is all a bit awkward because there's no way for an
extension to integrate with the built-in permissions mechanisms for
handling e.g. dropped users, and in-memory objects are a poor fit
anyway. Also, all of this is bound to have a performance cost: if
every access to a stash involves permissions checking, that's going to
add a possibly-significant amount of overhead to a code path that
might be very hot. And, in many environments, that permissions check
would be a complete waste of cycles. Things could maybe be simplified
by deciding that stash access can't be delegated: a stash has one and
only one owner, and it can affect only that user and not any other.
That is unlike what we do for built-in objects, but it simplifies the
permissions check to strcmp(), which is super-cheap compared to
catalog lookups. All in all, I don't really know which way to jump
here: what I've got right now looks almost stupidly primitive, and I
suspect it is, but adding complexity along what seem to be the obvious
lines isn't an obvious win, either.

6. How do we tell users when there's a problem? Right now, the only
available answer is to set pg_plan_advice.feedback_warnings, which I
don't think is unreasonable. If you're using advice as intended, i.e.
only for particular queries that really need it, then it really
shouldn't be generating any output unless something's gone wrong, but
I also don't think everyone is going to want this information going to
the main log file. Somehow percolating the advice feedback back to the
advisor that provided it -- pg_stash_advice or whatever -- feels like
a thing that is probably worth doing. pg_stash_advice could then
summarize the feedback and provide reports: hey, look, of the 100
queries for which you stashed advice, 97 of them always showed /*
matched */ for every item of advice, but the last 3 had varying
numbers of other results. Being able to query that via SQL seems like
it would be quite useful. I tend to think that it's almost a given
that we're going to eventually want something like this, but the
details aren't entirely clear to me. It could for example be developed
as a separate extension that can work for any advisor, rather than
being coupled to pg_stash_advice specifically -- but I'm also not
saying that's better, and I think it might be worse. Figuring out
exactly what we want to do here is a lot less critical than the items
mentioned above because, no matter what we do about any of those
things, this can always be added afterwards if and when desired. But,
I'm mentioning it for completeness.

If there are other design axes we should be talking about, I'm keen to
hear them. This is just what came to mind off-hand. Of course, I'm
also interested in everyone's views on what the right decisions are
from among the available options (or other options they may have in
mind).

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-29 18:58  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-03-29 18:58 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

Hi Robert,

On Fri, Mar 27, 2026 at 8:44 AM Robert Haas <[email protected]> wrote:
>
> On Fri, Mar 27, 2026 at 4:31 AM Lukas Fittl <[email protected]> wrote:
> > For the other one 0004 (pg_stash_advice) I feel its worth trying to
> > get it in, if we can figure out the remaining issues. I'll try to do
> > another pass on that tomorrow after some sleep.
>
> Let me just talk a little about the way that I see the space of
> possible designs here. Let's set aside what we do or do not have time
> to code for a moment and just think about what makes some kind of
> sense in theory. I believe we can divide this up along a few different
> axes:
>
> 1. Where is advice stored for on-the-fly retrieval? Possible answers
> include: in shared memory, in local memory, in a table, on disk. But
> realistically, I doubt that "in a table" is a realistic option. Even
> if we hard-coded a direct index lookup i.e. without going through the
> query planner, I think this would be a fairly expensive thing to do
> for every query, and if this is going to be usable on production
> systems, it has to be fast. I am absolutely certain that "on disk" is
> not a realistic option. Local memory is an option. It has the
> advantage of making server-lifespan memory leaks impossible, and the
> further advantage of avoiding contention between different backends,
> since each backend would have its own copy of the data. The big
> disadvantage is memory consumption. If we think the advice store is
> going to be small, then that might be fine, but somebody is
> potentially going to have thousands of advice strings stored,
> duplicating that across hundreds (or, gulp, thousands) of backends is
> pretty bad.

I've been pondering this particular question for the last few days
(because pg_hint_plan uses a table, so in a sense the question is, why
not do that), and I've come to the conclusion that I think your choice
of shared memory seems reasonable, especially in combination with the
stash GUC being a way to control which stash gets used.

I don't think local memory makes as much sense, because realistically
one would want their advice applied to all backends, and whilst we
could invent some synchronization mechanism, that seems more brittle
than managing shared memory.

The other problem of using a table (if we were to go that route),
besides performance overhead, is that advice would have to be tied to
individual databases where the extension was created - and for
multi-tenant use cases where you have a one-customer-per-database
setup, it'd basically be unusable. I think the combination of shared
memory and the GUC mechanism would work really well for that since you
could pick the advice stash to use for a given database using ALTER
DATABASE, without repeating the advice definitions.

It is worth noting in such a use case, one could still have a problem
with queryids being different between databases, but as of 787514b30bb
("Use relation name instead of OID in query jumbling for
RangeTblEntry") that should no longer be the case, if the
schemas/queries match.

>
> 2. What provision do we have for durability? Possible answers include:
> in a table, on disk, nothing. I went with nothing, partly on the
> theory that it gives users more flexibility. We don't really care
> where they store their query IDs and advice strings, as long as they
> have a way to feed them into the mechanism. But of course I was also
> trying to save myself implementation complexity. There are some tricky
> things about a table: as implemented, the advice stores are
> cluster-wide objects, but tables are database objects. If we're
> supposed to automatically load advice strings from a table that might
> be in another database into an in-memory store, well, we can't. That
> could be fixed by scoping stores to a specific database, which would
> be inconvenient only for users who have the same schema in multiple
> databases and would want to share stashes across DBs, which is
> probably not common. A disk file is also an option. It requires
> inventing some kind of custom format that we can generate and parse,
> which has some complexity, but reading from a table has some
> complexity too; I'm not sure which is messier.

I think a simple disk file is the way to go, similar to how
autoprewarm works with its "autoprewarm.blocks" file. Its a bit
awkward that that just sits in the main data directory, but since
pg_prewarm already does it today, I think its okay to have another
contrib module do the same. As noted I'm mainly worried about restarts
that the user didn't control, causing advice that was set to be lost.

I've attached a patch of how that could look like on top of your v23,
that copies the modified stash information to a
"pg_stash_advice.entries" file, and loads it after restarts.

> 3. How do we limit memory usage? One possible approach involves
> limiting the size of the hash table by entries or by memory
> consumption, but I don't think that's too valuable if that's all we
> do, because presumably all that's going to do is annoy people who hit
> the limit. It could make sense if we switched to a design where the
> superuser creates the stash, assigns it a memory limit, and then
> there's a way to give permission to use that stash to some particular
> user who is bound by the memory limit. In that kind of design, the
> person setting aside the memory has different and superior privileges
> to the person using it, so the cap serves a real purpose. A
> complicating factor here is that dshash doesn't seem to be well set up
> to enforce memory limits. You can do it if each dshash uses a separate
> DSA, but that's clunky, too. A completely different direction is to
> treat the in-memory component of the system as a cache, backed by a
> table or file from which cold entries are retrieved as needed. The
> problem with this - and I think it's a pretty big problem - is that
> performance will probably fall off a cliff as soon as you overflow the
> cache. I mean, it might not, if most of the requests can be satisfied
> from cache and a small percentage get fetched from cold storage, but
> if it's a case where a uniformly-accessed working set is 10% larger
> than the cache, the cache hit ratio will go down the tubes and so will
> performance.

Because the number of entries here is controlled by the user (i.e. its
not a function of the workload, but a function of how much advice you
as a user have set), I'm much less worried about memory usage, as long
as we document it clearly how to measure the amount of memory used.

We could also consider having a parameter that sets a maximum
size/number of entries, and require you to remove entries if that is
exceeded.

I agree on your concerns that a hybrid design (i.e. one that falls
back to on-disk) has a performance cliff that will be unexpected. I
don't think we need to solve for this use case of having that many
advice strings for now, as I think the main utility of pg_stash_advice
is to fix a handful of badly behaving queries through manual
intervention, not to automatically fix thousands of them.

> 4. How do we match advice strings to queries? The query ID is the
> obvious answer, but you could also think of having an option to match
> on query text, for cases when query IDs collide. You could do
> something like store a tag in the query string or in a GUC and look
> that up, instead of the query ID, but then I'm not sure why you don't
> just store the advice string instead of the tag, and then you don't
> need a hash table lookup anymore. You could do some kind of pattern
> matching on the query string rather than using the query ID, but that
> feels like it would be hard to get right, and also probably more
> expensive. There are probably other options here but I don't know what
> they are. I doubt that it makes sense from a performance standpoint to
> delegate out to a function written in a high-level language, and if
> you want to delegate to C code then you can just forget about
> pg_stash_advice and just use the add_advisor hook directly. I really
> feel like I'm probably missing some possible techniques, here. I
> wonder if other people will come up with some clever ideas.

I think for what makes sense in tree at this point, simple queryid
matching is the way to go.

I'm sure there are better ways to do matching of queries (i.e. make it
dependent on parameters in some way, be smart about significant table
size changes, etc), but we can let the community iterate on that out
of tree, since they can just build an extension like pg_stash_advice
that use the functions provided by pg_plan_advice.

> 5. What should the security model be? Right now it's as simple as can
> be: the superuser gets to decide who can use the mechanism. But also,
> not to be overlooked, an individual session can always opt out of
> automatic advice application by clearing the stash_name GUC. It
> shouldn't be possible to force wrong answers on another session even
> if you can impose arbitrary plan_advice, but there is a good chance
> that you could find a way to make their session catastrophically slow,
> so it's good that users can opt themselves out of the mechanism.
> Nonetheless, if we really want to have mutually untrusting users be
> able to use this facility, then stashes should have owners, and you
> should only be able to access a stash whose owner has authorized you
> to do so. This is all a bit awkward because there's no way for an
> extension to integrate with the built-in permissions mechanisms for
> handling e.g. dropped users, and in-memory objects are a poor fit
> anyway. Also, all of this is bound to have a performance cost: if
> every access to a stash involves permissions checking, that's going to
> add a possibly-significant amount of overhead to a code path that
> might be very hot. And, in many environments, that permissions check
> would be a complete waste of cycles. Things could maybe be simplified
> by deciding that stash access can't be delegated: a stash has one and
> only one owner, and it can affect only that user and not any other.
> That is unlike what we do for built-in objects, but it simplifies the
> permissions check to strcmp(), which is super-cheap compared to
> catalog lookups. All in all, I don't really know which way to jump
> here: what I've got right now looks almost stupidly primitive, and I
> suspect it is, but adding complexity along what seem to be the obvious
> lines isn't an obvious win, either.

I think the current trade-off is probably okay in terms of requiring a
superuser or its equivalent to grant access to create stash entries,
and allow unprivileged users to opt out of applied advice.

In practice for a good amount of our user base these days the question
will be "Does my cloud provider give me access to create stash
entries", so its maybe worth thinking about if we could also allow
pg_maintain to manage entries by default?

> 6. How do we tell users when there's a problem? Right now, the only
> available answer is to set pg_plan_advice.feedback_warnings, which I
> don't think is unreasonable. If you're using advice as intended, i.e.
> only for particular queries that really need it, then it really
> shouldn't be generating any output unless something's gone wrong, but
> I also don't think everyone is going to want this information going to
> the main log file. Somehow percolating the advice feedback back to the
> advisor that provided it -- pg_stash_advice or whatever -- feels like
> a thing that is probably worth doing. pg_stash_advice could then
> summarize the feedback and provide reports: hey, look, of the 100
> queries for which you stashed advice, 97 of them always showed /*
> matched */ for every item of advice, but the last 3 had varying
> numbers of other results. Being able to query that via SQL seems like
> it would be quite useful. I tend to think that it's almost a given
> that we're going to eventually want something like this, but the
> details aren't entirely clear to me. It could for example be developed
> as a separate extension that can work for any advisor, rather than
> being coupled to pg_stash_advice specifically -- but I'm also not
> saying that's better, and I think it might be worse. Figuring out
> exactly what we want to do here is a lot less critical than the items
> mentioned above because, no matter what we do about any of those
> things, this can always be added afterwards if and when desired. But,
> I'm mentioning it for completeness.

I think figuring out a feedback mechanism would be a good thing, but
we could definitely add that in a later release without a major design
change of pg_stash_advice, I think. And as you note,
pg_plan_advice.feedback_warnings exists as a mechanism today to
validate whether advice was applied as expected.

> If there are other design axes we should be talking about, I'm keen to
> hear them. This is just what came to mind off-hand. Of course, I'm
> also interested in everyone's views on what the right decisions are
> from among the available options (or other options they may have in
> mind).

I can't think of other angles at this point, at least not in the
context of what it makes sense to do in-tree for this release.

Thanks,
Lukas

-- 
Lukas Fittl


Attachments:

  [application/octet-stream] nocfbot-0001-Make-pg_stash_advice-dump-advice-to-disk-wh.patch (12.3K, 2-nocfbot-0001-Make-pg_stash_advice-dump-advice-to-disk-wh.patch)
  download | inline diff:
From 6c02fc9318ef19c15b00ad5376b7717bfa92725f Mon Sep 17 00:00:00 2001
From: Lukas Fittl <[email protected]>
Date: Sun, 29 Mar 2026 11:47:27 -0700
Subject: [PATCH vnocfbot] Make pg_stash_advice dump advice to disk when
 changed, load after restart

---
 contrib/pg_stash_advice/pg_stash_advice.c | 356 ++++++++++++++++++++++
 doc/src/sgml/pgstashadvice.sgml           |  10 +-
 2 files changed, 362 insertions(+), 4 deletions(-)

diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
index 22122236694..7dc52485617 100644
--- a/contrib/pg_stash_advice/pg_stash_advice.c
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -18,6 +18,8 @@
  */
 #include "postgres.h"
 
+#include <unistd.h>
+
 #include "common/hashfn.h"
 #include "common/string.h"
 #include "fmgr.h"
@@ -26,12 +28,15 @@
 #include "nodes/queryjumble.h"
 #include "pg_plan_advice.h"
 #include "storage/dsm_registry.h"
+#include "storage/fd.h"
 #include "storage/lwlock.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
 #include "utils/tuplestore.h"
 
+#define PGSA_DUMP_FILE		"pg_stash_advice.entries"
+
 PG_MODULE_MAGIC;
 
 PG_FUNCTION_INFO_V1(pg_create_advice_stash);
@@ -50,6 +55,7 @@ typedef struct pgsa_shared_state
 	dsa_handle	area;
 	dshash_table_handle stash_hash;
 	dshash_table_handle entry_hash;
+	bool		file_loaded;
 } pgsa_shared_state;
 
 typedef struct pgsa_stash
@@ -154,6 +160,10 @@ static void pgsa_init_shared_state(void *ptr, void *arg);
 static uint64 pgsa_lookup_stash_id(char *stash_name);
 static void pgsa_set_advice_string(char *stash_name, int64 queryId,
 								   char *advice_string);
+static void pgsa_dump_to_file(void);
+static void pgsa_load_from_file(void);
+static char *pgsa_escape_string(char *str);
+static char *pgsa_unescape_string(char *str);
 
 /*
  * Initialize this module.
@@ -201,6 +211,7 @@ pg_create_advice_stash(PG_FUNCTION_ARGS)
 	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
 	pgsa_create_stash(stash_name);
 	LWLockRelease(&pgsa_state->lock);
+	pgsa_dump_to_file();
 	PG_RETURN_VOID();
 }
 
@@ -218,6 +229,7 @@ pg_drop_advice_stash(PG_FUNCTION_ARGS)
 	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
 	pgsa_drop_stash(stash_name);
 	LWLockRelease(&pgsa_state->lock);
+	pgsa_dump_to_file();
 	PG_RETURN_VOID();
 }
 
@@ -441,6 +453,7 @@ pg_set_stashed_advice(PG_FUNCTION_ARGS)
 		pgsa_set_advice_string(stash_name, queryId, advice_string);
 	}
 
+	pgsa_dump_to_file();
 	PG_RETURN_VOID();
 }
 
@@ -614,6 +627,21 @@ pgsa_attach(void)
 
 	/* Restore previous memory context. */
 	MemoryContextSwitchTo(oldcontext);
+
+	/*
+	 * If the shared state was just created (i.e. after a server restart or
+	 * crash), try to restore stash data from the dump file. Use the
+	 * file_loaded flag under the lock to ensure only one backend does this.
+	 */
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	if (!pgsa_state->file_loaded)
+	{
+		pgsa_state->file_loaded = true;
+		LWLockRelease(&pgsa_state->lock);
+		pgsa_load_from_file();
+	}
+	else
+		LWLockRelease(&pgsa_state->lock);
 }
 
 /*
@@ -796,6 +824,7 @@ pgsa_init_shared_state(void *ptr, void *arg)
 	state->area = DSA_HANDLE_INVALID;
 	state->stash_hash = DSHASH_HANDLE_INVALID;
 	state->entry_hash = DSHASH_HANDLE_INVALID;
+	state->file_loaded = false;
 }
 
 /*
@@ -898,3 +927,330 @@ pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
 		dsa_free(pgsa_dsa_area, old_dp);
 	LWLockRelease(&pgsa_state->lock);
 }
+
+/*
+ * Dump all advice stash data to a file.
+ *
+ * The file format is a simple text format:
+ *   Line 1: <<num_stashes>>
+ *   Next num_stashes lines: one stash name per line
+ *   Remaining lines: stash_name\tquery_id\tadvice_string
+ *
+ * Stash names and advice strings are backslash-escaped where needed.
+ */
+static void
+pgsa_dump_to_file(void)
+{
+	FILE	   *file;
+	char		transient_dump_file_path[MAXPGPATH];
+	dshash_seq_status iter;
+	pgsa_stash *stash;
+	pgsa_entry *entry;
+	pgsa_stash_name_table_hash *nhash;
+	int			num_stashes = 0;
+	int			ret;
+
+	Assert(pgsa_entry_dshash != NULL);
+
+	/* Open a temporary file for writing. */
+	snprintf(transient_dump_file_path, MAXPGPATH, "%s.tmp", PGSA_DUMP_FILE);
+	file = AllocateFile(transient_dump_file_path, "w");
+	if (!file)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not open file \"%s\": %m",
+						transient_dump_file_path)));
+
+	/*
+	 * Build an ID->name lookup table. We also use this to count stashes (for
+	 * the header) and to write stash names after the header.
+	 */
+	nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iter, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iter)) != NULL)
+	{
+		pgsa_stash_name *n;
+		bool		found;
+
+		n = pgsa_stash_name_table_insert(nhash, stash->pgsa_stash_id, &found);
+		Assert(!found);
+		n->name = pstrdup(stash->name);
+		num_stashes++;
+	}
+	dshash_seq_term(&iter);
+
+	/* Write the header and stash names. */
+	ret = fprintf(file, "<<%d>>\n", num_stashes);
+	if (ret >= 0)
+	{
+		pgsa_stash_name_table_iterator i;
+
+		pgsa_stash_name_table_start_iterate(nhash, &i);
+		while (ret >= 0)
+		{
+			pgsa_stash_name *n = pgsa_stash_name_table_iterate(nhash, &i);
+
+			if (n == NULL)
+				break;
+			ret = fprintf(file, "%s\n", pgsa_escape_string(n->name));
+		}
+	}
+
+	/* Write entries: escaped_stash_name\tquery_id\tescaped_advice. */
+	if (ret >= 0)
+	{
+		dshash_seq_init(&iter, pgsa_entry_dshash, true);
+		while ((entry = dshash_seq_next(&iter)) != NULL)
+		{
+			pgsa_stash_name *n;
+			char	   *advice_string;
+
+			if (entry->advice_string == InvalidDsaPointer)
+				continue;
+
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n == NULL)
+				continue;		/* orphan entry, skip */
+
+			advice_string = dsa_get_address(pgsa_dsa_area,
+											entry->advice_string);
+			ret = fprintf(file, "%s\t%" PRId64 "\t%s\n",
+						  pgsa_escape_string(n->name),
+						  entry->key.queryId,
+						  pgsa_escape_string(advice_string));
+			if (ret < 0)
+				break;
+		}
+		dshash_seq_term(&iter);
+	}
+
+	/* Handle any write error. */
+	if (ret < 0)
+	{
+		int			save_errno = errno;
+
+		FreeFile(file);
+		unlink(transient_dump_file_path);
+		errno = save_errno;
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not write to file \"%s\": %m",
+						transient_dump_file_path)));
+	}
+
+	/* Close the file and rename it into place atomically. */
+	ret = FreeFile(file);
+	if (ret != 0)
+	{
+		int			save_errno = errno;
+
+		unlink(transient_dump_file_path);
+		errno = save_errno;
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not close file \"%s\": %m",
+						transient_dump_file_path)));
+	}
+
+	(void) durable_rename(transient_dump_file_path, PGSA_DUMP_FILE, ERROR);
+}
+
+/*
+ * Load advice stash data from the dump file.
+ *
+ * This is called once when the shared memory state is first initialized
+ * (i.e. after a server restart or crash recovery), to restore the previously
+ * saved stash contents.
+ *
+ * Errors during loading are reported as warnings so that a corrupt dump file
+ * does not prevent the server from starting.
+ */
+static void
+pgsa_load_from_file(void)
+{
+	FILE	   *file;
+	int			num_stashes;
+	int			num_entries = 0;
+	int			i;
+	StringInfoData buf;
+
+	file = AllocateFile(PGSA_DUMP_FILE, "r");
+	if (!file)
+	{
+		if (errno == ENOENT)
+			return;				/* no dump file, nothing to load */
+		ereport(WARNING,
+				(errcode_for_file_access(),
+				 errmsg("could not open file \"%s\": %m", PGSA_DUMP_FILE)));
+		return;
+	}
+
+	/* Read the header. */
+	if (fscanf(file, "<<%d>>\n", &num_stashes) != 1)
+	{
+		FreeFile(file);
+		ereport(WARNING,
+				errmsg("pg_stash_advice dump file has corrupted header"));
+		return;
+	}
+
+	initStringInfo(&buf);
+
+	/* Read and create stash names. */
+	for (i = 0; i < num_stashes; i++)
+	{
+		if (!pg_get_line_buf(file, &buf))
+		{
+			FreeFile(file);
+			pfree(buf.data);
+			ereport(WARNING,
+					errmsg("pg_stash_advice dump file is truncated at stash %d",
+						   i + 1));
+			return;
+		}
+
+		/* Strip the trailing newline. */
+		if (buf.len > 0 && buf.data[buf.len - 1] == '\n')
+			buf.data[--buf.len] = '\0';
+		{
+			char	   *name = pgsa_unescape_string(buf.data);
+
+			/* Skip duplicates rather than ERRORing like pgsa_create_stash. */
+			if (pgsa_lookup_stash_id(name) == 0)
+			{
+				LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+				pgsa_create_stash(name);
+				LWLockRelease(&pgsa_state->lock);
+			}
+		}
+	}
+
+	/* Read and restore entries until EOF. */
+	while (pg_get_line_buf(file, &buf))
+	{
+		char	   *stash_name;
+		char	   *queryid_str;
+		char	   *advice_string;
+		int64		queryId;
+		ErrorSaveContext escontext = {T_ErrorSaveContext};
+
+		/* Strip the trailing newline. */
+		if (buf.len > 0 && buf.data[buf.len - 1] == '\n')
+			buf.data[--buf.len] = '\0';
+
+		/* Parse: stash_name\tquery_id\tadvice_string */
+		stash_name = buf.data;
+		queryid_str = strchr(stash_name, '\t');
+		if (queryid_str == NULL)
+			goto malformed;
+		*queryid_str++ = '\0';
+
+		advice_string = strchr(queryid_str, '\t');
+		if (advice_string == NULL)
+			goto malformed;
+		*advice_string++ = '\0';
+
+		queryId = pg_strtoint64_safe(queryid_str, (Node *) &escontext);
+		if (SOFT_ERROR_OCCURRED(&escontext))
+			goto malformed;
+
+		pgsa_set_advice_string(pgsa_unescape_string(stash_name),
+							   queryId,
+							   pgsa_unescape_string(advice_string));
+		num_entries++;
+		continue;
+
+malformed:
+		ereport(WARNING,
+				errmsg("pg_stash_advice dump file has malformed entry, skipping"));
+	}
+
+	FreeFile(file);
+	pfree(buf.data);
+
+	ereport(LOG,
+			errmsg("pg_stash_advice: loaded %d stashes with %d entries from \"%s\"",
+				   num_stashes, num_entries, PGSA_DUMP_FILE));
+}
+
+/*
+ * Backslash-escape the string so it can be written to a tab-separated file.
+ *
+ * The escaped characters are backslash, tab, and newline.
+ */
+static char *
+pgsa_escape_string(char *str)
+{
+	StringInfoData buf;
+
+	if (!strpbrk(str, "\\\t\n"))
+		return str;
+
+	initStringInfo(&buf);
+	for (const char *p = str; *p; p++)
+	{
+		switch (*p)
+		{
+			case '\\':
+				appendStringInfoString(&buf, "\\\\");
+				break;
+			case '\t':
+				appendStringInfoString(&buf, "\\t");
+				break;
+			case '\n':
+				appendStringInfoString(&buf, "\\n");
+				break;
+			case '\r':
+				appendStringInfoString(&buf, "\\r");
+				break;
+			default:
+				appendStringInfoChar(&buf, *p);
+				break;
+		}
+	}
+
+	return buf.data;
+}
+
+/*
+ * Unescape a string that was escaped for serializing to the on-disk file.
+ */
+static char *
+pgsa_unescape_string(char *str)
+{
+	StringInfoData buf;
+
+	if (!strchr(str, '\\'))
+		return pstrdup(str);
+
+	initStringInfo(&buf);
+	for (const char *p = str; *p; p++)
+	{
+		if (*p == '\\' && p[1] != '\0')
+		{
+			p++;
+			switch (*p)
+			{
+				case '\\':
+					appendStringInfoChar(&buf, '\\');
+					break;
+				case 't':
+					appendStringInfoChar(&buf, '\t');
+					break;
+				case 'n':
+					appendStringInfoChar(&buf, '\n');
+					break;
+				case 'r':
+					appendStringInfoChar(&buf, '\r');
+					break;
+				default:
+					/* Unrecognized escape; keep as-is. */
+					appendStringInfoChar(&buf, *p);
+					break;
+			}
+		}
+		else
+			appendStringInfoChar(&buf, *p);
+	}
+	return buf.data;
+}
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
index 089fc66446f..ce2c8ec3ab9 100644
--- a/doc/src/sgml/pgstashadvice.sgml
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -15,10 +15,12 @@
   <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
   strings. Whenever a session is asked to plan a query whose query ID appears
   in the relevant advice stash, the plan advice string is automatically applied
-  to guide planning. Note that advice stashes exist purely in memory. This
-  means both that it is important to be mindful of memory consumption when
-  deciding how much plan advice to stash, and also that advice stashes must
-  be recreated and repopulated whenever the server is restarted.
+  to guide planning. Advice stashes are held in memory, so it is important
+  to be mindful of memory consumption when deciding how much plan advice to
+  stash. The contents are automatically saved to a file called
+  <filename>pg_stash_advice.entries</filename> whenever they are modified,
+  and restored when the first session attaches after a server restart
+  (including after a crash).
  </para>
 
  <para>
-- 
2.47.1



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-03-30 14:53  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-03-30 14:53 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

Hi,

Thanks for taking the time to respond. My reading of your comments is
that we are in overall agreement on the design, with the possible
exception of persisting data cross restarts. I will write more about
that topic below; but I think if that's the only design disagreement
we have, it makes sense to go forward with committing the patch that I
have, and we can continue to discuss whether we want to add something
related to persistence. The only reason not to do that would be if
there were a consensus that the lack of a persistence framework was
such a critical defect that we shouldn't ship this at all without
that, but I don't agree with that idea and I think it would be a
pretty strong position for someone to take.

On Sun, Mar 29, 2026 at 2:59 PM Lukas Fittl <[email protected]> wrote:
> I think a simple disk file is the way to go, similar to how
> autoprewarm works with its "autoprewarm.blocks" file. Its a bit
> awkward that that just sits in the main data directory, but since
> pg_prewarm already does it today, I think its okay to have another
> contrib module do the same. As noted I'm mainly worried about restarts
> that the user didn't control, causing advice that was set to be lost.
>
> I've attached a patch of how that could look like on top of your v23,
> that copies the modified stash information to a
> "pg_stash_advice.entries" file, and loads it after restarts.

I'll be honest: I don't like this design much at all, but I do see the
practical advantages of it, and we have done similar things elsewhere,
in particular in autoprewarm. Before I get to the specifics of your
patch, let me complain about some things that I don't like at the
design level. We lose a lot by directing data through a bespoke
mechanism rather than handling it as table data. There are no
checksums, so we have less protection against corruption. There is no
write-ahead logging, so data does not make it to standbys, which is
more of a potential issue for pg_stash_advice than it is for
autoprewarm. All the code to read and write the file is specific to
this contrib module, so it can have its own bugs separate from every
similar module's bugs. The data can't easily be examined and
manipulated from SQL as table data can. It's just a messy one-off that
solves a practical problem but is otherwise not very nice. Of course,
sometimes such messy one-offs are the right answer.

In terms of the patch itself, the concurrency situation here seems
noticeably worse than with autoprewarm. In that case, there's only one
authorized writer at a time, tracked via pid_using_dumpfile. But in
this case, it seems like multiple backends could be writing to the
temporary dump file at the same time, which could result in a
corrupted file that doesn't reload properly. Your code also has a race
condition when reloading the data: the first arriving backend tries to
reload the flat file, but any other backends that arrive while that's
in progress see no stashed advice, and if the load fails for some
reason, it's never retried, and the first modification to the
in-memory state will clobber the file. autoprewarm has this issue to
some extent as well, but that's more OK there because recreating the
contents of shared buffers is only an approximate good, but people
probably don't want their stashed advice to disappear out from under
them if it was billed as persistent. That said, I'm not entirely
opposed to a design where there's a small window where the advice
stash is empty after a restart, because avoiding that means that it
has to be safe to do the reload of saved advice from the middle of a
query planning cycle, which is probably true with a flat-file design
but wouldn't be true with a table. Still, I don't know whether the
current behavior is deliberate or accidental.

I also feel a bit uncomfortable with the idea of rewriting the entire
file on every single change. If the hypothesis that this is only for
adjusting the behavior of a small number of critical queries is
correct, then it won't matter, but if people start using this for lots
of queries, it's potentially painful. Neither autoprewarm nor
pg_stat_statements does that. pg_stat_statements reads data only at
postmaster startup and writes data only at postmaster shutdown, so it
simply accepts loss of incremental changes in case of a crash, but
that also means it doesn't read and write the file repeatedly.
autoprewarm writes the file periodically from a background worker so
that the on-disk state doesn't drift too far out of sync with what's
in memory, without promising perfect durability. Both of those
placements have the further advantage that the reading and writing of
the file is not being done "in medias res," which does seem to have
certain advantages from a robustness perspective. For example, without
necessarily endorsing this design, suppose you added a background
worker and there are GUCs to configure the database that it connects
to and the query it executes to restore advice stashes. Or,
alternatively, a background worker that still uses a flat file. Either
way, that opens up design ideas like: when you see that the in-memory
stashes are not yet reloaded, you can decide to wait up to X seconds
for that to happen and then proceed anyway if it hasn't happened by
then. I'm not saying that is the right idea here necessarily, but it's
an option, whereas what you've done doesn't lend itself to that sort
of idea.

One other note is that fscanf() ending in a newline could eat up
whitespace at the start of the following line. Since a stash name can
begin with whitespace, that could be an issue.

> Because the number of entries here is controlled by the user (i.e. its
> not a function of the workload, but a function of how much advice you
> as a user have set), I'm much less worried about memory usage, as long
> as we document it clearly how to measure the amount of memory used.

The module doesn't have a built-in way to do that right now. Are you
thinking we would document that pg_get_dsm_registry_allocations() can
be used?

> In practice for a good amount of our user base these days the question
> will be "Does my cloud provider give me access to create stash
> entries", so its maybe worth thinking about if we could also allow
> pg_maintain to manage entries by default?

Wouldn't it make more sense for the cloud provider to grant execute
permissions on these functions to pg_maintain if they are so inclined?
This is a brand-new facility, so I think we had better be conservative
in terms of default permissions.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-01 02:25  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 2 replies; 133+ messages in thread

From: Lukas Fittl @ 2026-04-01 02:25 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Mar 30, 2026 at 7:53 AM Robert Haas <[email protected]> wrote:
>
> Hi,
>
> Thanks for taking the time to respond. My reading of your comments is
> that we are in overall agreement on the design, with the possible
> exception of persisting data cross restarts. I will write more about
> that topic below; but I think if that's the only design disagreement
> we have, it makes sense to go forward with committing the patch that I
> have, and we can continue to discuss whether we want to add something
> related to persistence. The only reason not to do that would be if
> there were a consensus that the lack of a persistence framework was
> such a critical defect that we shouldn't ship this at all without
> that, but I don't agree with that idea and I think it would be a
> pretty strong position for someone to take.

Yeah, I think my position is that having a solution to persistence
would be very good, but if that's not doable for this release, I think
we have a potential way forward in future releases, at least when it
comes to being restart-safe.

That said, I still think it'll make a big difference in practice to be
restart safe right away, if we can make it happen.

>
> On Sun, Mar 29, 2026 at 2:59 PM Lukas Fittl <[email protected]> wrote:
> > I think a simple disk file is the way to go, similar to how
> > autoprewarm works with its "autoprewarm.blocks" file. Its a bit
> > awkward that that just sits in the main data directory, but since
> > pg_prewarm already does it today, I think its okay to have another
> > contrib module do the same. As noted I'm mainly worried about restarts
> > that the user didn't control, causing advice that was set to be lost.
> >
> > I've attached a patch of how that could look like on top of your v23,
> > that copies the modified stash information to a
> > "pg_stash_advice.entries" file, and loads it after restarts.
>
> I'll be honest: I don't like this design much at all, but I do see the
> practical advantages of it, and we have done similar things elsewhere,
> in particular in autoprewarm. Before I get to the specifics of your
> patch, let me complain about some things that I don't like at the
> design level. We lose a lot by directing data through a bespoke
> mechanism rather than handling it as table data. There are no
> checksums, so we have less protection against corruption. There is no
> write-ahead logging, so data does not make it to standbys, which is
> more of a potential issue for pg_stash_advice than it is for
> autoprewarm. All the code to read and write the file is specific to
> this contrib module, so it can have its own bugs separate from every
> similar module's bugs. The data can't easily be examined and
> manipulated from SQL as table data can. It's just a messy one-off that
> solves a practical problem but is otherwise not very nice. Of course,
> sometimes such messy one-offs are the right answer.

I think if we wanted a table, we should make it a table - but I think
the fact that we want this to be low-overhead for running queries to
examine whether there is anything for them to apply, would require
some kind of cache in front of it, and that gets complicated pretty
quickly.

For the file-based direction, just for reference, I'm attaching an
updated version of that (on top of Robert's earlier v23), that
utilizes a background worker to write out the dump file as needed, at
most every 60 seconds. It also reworks some of the output logic to do
better memory management, and uses a TSV file format that can be
easily read again.

To be clear, I think its okay to go ahead with merging pg_stash_advice
without that and make it a best effort to get the file saving in too -
but I think with the current design in this patch represents a
reasonable solution to what we can do in terms of persistence across
restarts in either 19 or 20.

>
> > Because the number of entries here is controlled by the user (i.e. its
> > not a function of the workload, but a function of how much advice you
> > as a user have set), I'm much less worried about memory usage, as long
> > as we document it clearly how to measure the amount of memory used.
>
> The module doesn't have a built-in way to do that right now. Are you
> thinking we would document that pg_get_dsm_registry_allocations() can
> be used?

Yeah, for example. Alternatively we could provide a function/view that
lists all advice across all stashes, so you can more easily see the
result size of that and estimate what the in-memory use is. But
pointing to pg_get_dsm_registry_allocations seems easier.

> > In practice for a good amount of our user base these days the question
> > will be "Does my cloud provider give me access to create stash
> > entries", so its maybe worth thinking about if we could also allow
> > pg_maintain to manage entries by default?
>
> Wouldn't it make more sense for the cloud provider to grant execute
> permissions on these functions to pg_maintain if they are so inclined?
> This is a brand-new facility, so I think we had better be conservative
> in terms of default permissions.

I guess. I'm always worried that providers get that wrong and forget
to give end users the permissions - but I suppose end users can
complain to their providers if that's the case.

I've done another look over pg_set_stashed_advice and I think its in
good shape. The only trailing thought I have is that we could consider
running a fuzzer against the pg_set_advice function in particular,
just to see if anything pops up (beyond having the ability to make a
very large memory allocation through a large advice string, which is
maybe a problem?).

Thanks,
Lukas

-- 
Lukas Fittl


Attachments:

  [application/octet-stream] vnocfbot-2-0001-Make-pg_stash_advice-dump-advice-to-disk-.patch (18.0K, 2-vnocfbot-2-0001-Make-pg_stash_advice-dump-advice-to-disk-.patch)
  download | inline diff:
From b739ca004ccdede260c541ba8c8dc1aa4e3ea15d Mon Sep 17 00:00:00 2001
From: Lukas Fittl <[email protected]>
Date: Sun, 29 Mar 2026 11:47:27 -0700
Subject: [PATCH vnocfbot-2] Make pg_stash_advice dump advice to disk when
 changed, load after restart

---
 contrib/pg_stash_advice/pg_stash_advice.c | 533 +++++++++++++++++++++-
 doc/src/sgml/pgstashadvice.sgml           |  31 +-
 2 files changed, 559 insertions(+), 5 deletions(-)

diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
index 22122236694..61fb12a1888 100644
--- a/contrib/pg_stash_advice/pg_stash_advice.c
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -18,22 +18,37 @@
  */
 #include "postgres.h"
 
+#include <unistd.h>
+
 #include "common/hashfn.h"
 #include "common/string.h"
 #include "fmgr.h"
 #include "funcapi.h"
+#include "miscadmin.h"
 #include "lib/dshash.h"
 #include "nodes/queryjumble.h"
 #include "pg_plan_advice.h"
+#include "postmaster/bgworker.h"
+#include "postmaster/interrupt.h"
 #include "storage/dsm_registry.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "storage/latch.h"
 #include "storage/lwlock.h"
+#include "storage/proc.h"
+#include "storage/procsignal.h"
+#include "utils/backend_status.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
 #include "utils/tuplestore.h"
 
+#define PGSA_DUMP_FILE		"pg_stash_advice.tsv"
+
 PG_MODULE_MAGIC;
 
+PGDLLEXPORT void pgsa_stash_advice_main(Datum main_arg);
+
 PG_FUNCTION_INFO_V1(pg_create_advice_stash);
 PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
 PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
@@ -50,6 +65,8 @@ typedef struct pgsa_shared_state
 	dsa_handle	area;
 	dshash_table_handle stash_hash;
 	dshash_table_handle entry_hash;
+	ProcNumber	bgworker_proc;
+	bool		dump_requested;
 } pgsa_shared_state;
 
 typedef struct pgsa_stash
@@ -131,8 +148,9 @@ static dshash_parameters pgsa_entry_dshash_parameters = {
 	LWTRANCHE_INVALID			/* gets set at runtime */
 };
 
-/* GUC variable */
+/* GUC variables */
 static char *pg_stash_advice_stash_name = "";
+static bool pg_stash_advice_save = true;
 
 /* Other global variables */
 static MemoryContext pg_stash_advice_mcxt;
@@ -154,6 +172,13 @@ static void pgsa_init_shared_state(void *ptr, void *arg);
 static uint64 pgsa_lookup_stash_id(char *stash_name);
 static void pgsa_set_advice_string(char *stash_name, int64 queryId,
 								   char *advice_string);
+static void pgsa_dump_to_file(void);
+static void pgsa_load_from_file(void);
+static void pgsa_request_dump(void);
+static void pgsa_detach_shmem(int code, Datum arg);
+static void pgsa_start_bgworker(void);
+static char *pgsa_escape_string(char *str);
+static char *pgsa_read_next_field(char **pp);
 
 /*
  * Initialize this module.
@@ -178,6 +203,28 @@ _PG_init(void)
 							   NULL,
 							   NULL);
 
+	if (process_shared_preload_libraries_in_progress)
+	{
+		/* can't define PGC_POSTMASTER variable after startup */
+		DefineCustomBoolVariable("pg_stash_advice.save",
+								 "Save and restore advice stash contents across restarts.",
+								 NULL,
+								 &pg_stash_advice_save,
+								 true,
+								 PGC_POSTMASTER,
+								 0,
+								 NULL,
+								 NULL,
+								 NULL);
+
+		/*
+		 * Register background worker for dumping entries to recover on
+		 * restart, if enabled.
+		 */
+		if (pg_stash_advice_save)
+			pgsa_start_bgworker();
+	}
+
 	MarkGUCPrefixReserved("pg_stash_advice");
 
 	/* Tell pg_plan_advice that we want to provide advice strings. */
@@ -201,6 +248,7 @@ pg_create_advice_stash(PG_FUNCTION_ARGS)
 	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
 	pgsa_create_stash(stash_name);
 	LWLockRelease(&pgsa_state->lock);
+	pgsa_request_dump();
 	PG_RETURN_VOID();
 }
 
@@ -218,6 +266,7 @@ pg_drop_advice_stash(PG_FUNCTION_ARGS)
 	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
 	pgsa_drop_stash(stash_name);
 	LWLockRelease(&pgsa_state->lock);
+	pgsa_request_dump();
 	PG_RETURN_VOID();
 }
 
@@ -441,6 +490,7 @@ pg_set_stashed_advice(PG_FUNCTION_ARGS)
 		pgsa_set_advice_string(stash_name, queryId, advice_string);
 	}
 
+	pgsa_request_dump();
 	PG_RETURN_VOID();
 }
 
@@ -796,6 +846,8 @@ pgsa_init_shared_state(void *ptr, void *arg)
 	state->area = DSA_HANDLE_INVALID;
 	state->stash_hash = DSHASH_HANDLE_INVALID;
 	state->entry_hash = DSHASH_HANDLE_INVALID;
+	state->bgworker_proc = INVALID_PROC_NUMBER;
+	state->dump_requested = false;
 }
 
 /*
@@ -898,3 +950,482 @@ pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
 		dsa_free(pgsa_dsa_area, old_dp);
 	LWLockRelease(&pgsa_state->lock);
 }
+
+/*
+ * Background worker entry point.
+ *
+ * This worker loads the dump file on startup, then waits for dump requests
+ * from backends.  On shutdown, it performs a final dump.
+ */
+void
+pgsa_stash_advice_main(Datum main_arg)
+{
+	/* Establish signal handlers; once that's done, unblock signals. */
+	pqsignal(SIGTERM, SignalHandlerForShutdownRequest);
+	pqsignal(SIGHUP, SignalHandlerForConfigReload);
+	pqsignal(SIGUSR1, procsignal_sigusr1_handler);
+	BackgroundWorkerUnblockSignals();
+
+	/* Set up a session user so pgstat_bestart_final() can report it. */
+	InitializeSessionUserIdStandalone();
+
+	/* Report this worker in pg_stat_activity. */
+	pgstat_beinit();
+	pgstat_bestart_initial();
+	pgstat_bestart_final();
+
+	/* Attach to shared memory structures. */
+	pgsa_attach();
+
+	/*
+	 * Set on-detach hook so that our PID will be cleared on exit.
+	 *
+	 * NB: pg_stash_advice's state is stored in a DSM segment, and DSM
+	 * segments are detached before calling the on_shmem_exit callbacks, so we
+	 * must put pgsa_detach_shmem in the before_shmem_exit callback list.
+	 */
+	before_shmem_exit(pgsa_detach_shmem, 0);
+
+	/*
+	 * Store our PID in shared memory, unless there's already another worker
+	 * running.
+	 */
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	if (pgsa_state->bgworker_proc != INVALID_PROC_NUMBER)
+	{
+		LWLockRelease(&pgsa_state->lock);
+		ereport(LOG,
+				(errmsg("pg_stash_advice worker is already running under PID %d",
+						(int) GetPGProcByNumber(pgsa_state->bgworker_proc)->pid)));
+		return;
+	}
+	pgsa_state->bgworker_proc = MyProcNumber;
+	LWLockRelease(&pgsa_state->lock);
+
+	/* Load previously saved stash data from disk. */
+	pgsa_load_from_file();
+
+	/* Dump when requested, until shutdown. */
+	while (!ShutdownRequestPending)
+	{
+		bool		dump_requested = false;
+
+		/* In case of a SIGHUP, just reload the configuration. */
+		if (ConfigReloadPending)
+		{
+			ConfigReloadPending = false;
+			ProcessConfigFile(PGC_SIGHUP);
+		}
+
+		/* Check whether a dump has been requested. */
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		if (pgsa_state->dump_requested)
+		{
+			pgsa_state->dump_requested = false;
+			dump_requested = true;
+		}
+		LWLockRelease(&pgsa_state->lock);
+
+		if (dump_requested)
+			pgsa_dump_to_file();
+
+		/*
+		 * Sleep for up to 60 seconds before checking again.  This ensures we
+		 * coalesce multiple rapid changes into a single dump.
+		 */
+		(void) WaitLatch(MyLatch,
+						 WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH,
+						 60000L,
+						 PG_WAIT_EXTENSION);
+
+		ResetLatch(MyLatch);
+	}
+
+	/* Perform a final dump before exiting. */
+	pgsa_dump_to_file();
+}
+
+/*
+ * Signal the background worker to dump stash data to disk.
+ */
+static void
+pgsa_request_dump(void)
+{
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_state->dump_requested = true;
+	LWLockRelease(&pgsa_state->lock);
+}
+
+/*
+ * Clear our PID from shared memory on exit.
+ */
+static void
+pgsa_detach_shmem(int code, Datum arg)
+{
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	if (pgsa_state->bgworker_proc == MyProcNumber)
+		pgsa_state->bgworker_proc = INVALID_PROC_NUMBER;
+	LWLockRelease(&pgsa_state->lock);
+}
+
+/*
+ * Register the background worker.
+ */
+static void
+pgsa_start_bgworker(void)
+{
+	BackgroundWorker worker = {0};
+
+	worker.bgw_flags = BGWORKER_SHMEM_ACCESS;
+	worker.bgw_start_time = BgWorkerStart_ConsistentState;
+	strcpy(worker.bgw_library_name, "pg_stash_advice");
+	strcpy(worker.bgw_function_name, "pgsa_stash_advice_main");
+	strcpy(worker.bgw_name, "pg_stash_advice worker");
+	strcpy(worker.bgw_type, "pg_stash_advice worker");
+
+	RegisterBackgroundWorker(&worker);
+}
+
+/*
+ * Dump all advice stash data to a file.
+ *
+ * The file format is a simple TSV with a line-type prefix:
+ *   stash\tstash_name
+ *   entry\tstash_name\tquery_id\tadvice_string
+ *
+ * Stash names and advice strings are backslash-escaped where needed.
+ */
+static void
+pgsa_dump_to_file(void)
+{
+	FILE	   *file;
+	char		transient_dump_file_path[MAXPGPATH];
+	dshash_seq_status iter;
+	pgsa_stash *stash;
+	pgsa_entry *entry;
+	pgsa_stash_name_table_hash *nhash;
+	int			ret = 0;
+	MemoryContext tmpcxt;
+	MemoryContext oldcxt;
+
+	Assert(pgsa_entry_dshash != NULL);
+
+	/* Use a temporary context so all allocations are freed at the end. */
+	tmpcxt = AllocSetContextCreate(CurrentMemoryContext,
+								   "pg_stash_advice dump",
+								   ALLOCSET_DEFAULT_SIZES);
+	oldcxt = MemoryContextSwitchTo(tmpcxt);
+
+	/* Open a temporary file for writing. */
+	snprintf(transient_dump_file_path, MAXPGPATH, "%s.tmp", PGSA_DUMP_FILE);
+	file = AllocateFile(transient_dump_file_path, "w");
+	if (!file)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not open file \"%s\": %m",
+						transient_dump_file_path)));
+
+	/* Build an ID->name lookup table for writing entry lines. */
+	nhash = pgsa_stash_name_table_create(tmpcxt, 64, NULL);
+
+	/* Write stash lines. */
+	dshash_seq_init(&iter, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iter)) != NULL)
+	{
+		pgsa_stash_name *n;
+		bool		found;
+
+		n = pgsa_stash_name_table_insert(nhash, stash->pgsa_stash_id, &found);
+		Assert(!found);
+		n->name = pstrdup(stash->name);
+		ret = fprintf(file, "stash\t%s\n", pgsa_escape_string(n->name));
+		if (ret < 0)
+			break;
+	}
+	dshash_seq_term(&iter);
+
+	/* Write entry lines. */
+	if (ret >= 0)
+	{
+		dshash_seq_init(&iter, pgsa_entry_dshash, true);
+		while ((entry = dshash_seq_next(&iter)) != NULL)
+		{
+			pgsa_stash_name *n;
+			char	   *advice_string;
+
+			if (entry->advice_string == InvalidDsaPointer)
+				continue;
+
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n == NULL)
+				continue;		/* orphan entry, skip */
+
+			advice_string = dsa_get_address(pgsa_dsa_area,
+											entry->advice_string);
+			ret = fprintf(file, "entry\t%s\t%" PRId64 "\t%s\n",
+						  pgsa_escape_string(n->name),
+						  entry->key.queryId,
+						  pgsa_escape_string(advice_string));
+			if (ret < 0)
+				break;
+		}
+		dshash_seq_term(&iter);
+	}
+
+	/* Handle any write error. */
+	if (ret < 0)
+	{
+		int			save_errno = errno;
+
+		FreeFile(file);
+		unlink(transient_dump_file_path);
+		errno = save_errno;
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not write to file \"%s\": %m",
+						transient_dump_file_path)));
+	}
+
+	/* Close the file and rename it into place atomically. */
+	ret = FreeFile(file);
+	if (ret != 0)
+	{
+		int			save_errno = errno;
+
+		unlink(transient_dump_file_path);
+		errno = save_errno;
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not close file \"%s\": %m",
+						transient_dump_file_path)));
+	}
+
+	(void) durable_rename(transient_dump_file_path, PGSA_DUMP_FILE, ERROR);
+
+	MemoryContextSwitchTo(oldcxt);
+	MemoryContextDelete(tmpcxt);
+}
+
+/*
+ * Load advice stash data from the dump file.
+ *
+ * This is called once when the shared memory state is first initialized
+ * (i.e. after a server restart or crash recovery), to restore the previously
+ * saved stash contents.
+ *
+ * Errors during loading are reported as warnings so that a corrupt dump file
+ * does not prevent the server from starting.
+ */
+static void
+pgsa_load_from_file(void)
+{
+	FILE	   *file;
+	int			num_stashes = 0;
+	int			num_entries = 0;
+	int			num_malformed = 0;
+	char	   *line;
+
+	file = AllocateFile(PGSA_DUMP_FILE, "r");
+	if (!file)
+	{
+		if (errno == ENOENT)
+			return;				/* no dump file, nothing to load */
+		ereport(WARNING,
+				(errcode_for_file_access(),
+				 errmsg("could not open file \"%s\": %m", PGSA_DUMP_FILE)));
+		return;
+	}
+
+	/* Read lines until EOF. */
+	while ((line = pg_get_line(file, NULL)) != NULL)
+	{
+		char	   *p = line;
+		char	   *line_type;
+
+		/* Strip the trailing newline. */
+		pg_strip_crlf(line);
+
+		/* Split off the line type prefix (unescaped, plain keyword). */
+		line_type = pgsa_read_next_field(&p);
+		if (line_type == NULL)
+		{
+			num_malformed++;
+			pfree(line);
+			continue;
+		}
+
+		if (strcmp(line_type, "stash") == 0)
+		{
+			char	   *name = pgsa_read_next_field(&p);
+
+			if (name != NULL)
+			{
+				/*
+				 * Skip duplicates rather than ERRORing like
+				 * pgsa_create_stash.
+				 */
+				if (pgsa_lookup_stash_id(name) == 0)
+				{
+					LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+					pgsa_create_stash(name);
+					LWLockRelease(&pgsa_state->lock);
+				}
+				num_stashes++;
+				pfree(name);
+			}
+			else
+				num_malformed++;
+		}
+		else if (strcmp(line_type, "entry") == 0)
+		{
+			char	   *stash_name;
+			char	   *queryid_str;
+			char	   *advice_string;
+			int64		queryId;
+			ErrorSaveContext escontext = {T_ErrorSaveContext};
+
+			stash_name = pgsa_read_next_field(&p);
+			queryid_str = pgsa_read_next_field(&p);
+			advice_string = pgsa_read_next_field(&p);
+
+			if (stash_name == NULL || queryid_str == NULL ||
+				advice_string == NULL)
+			{
+				num_malformed++;
+				if (stash_name)
+					pfree(stash_name);
+				if (queryid_str)
+					pfree(queryid_str);
+				if (advice_string)
+					pfree(advice_string);
+				pfree(line_type);
+				pfree(line);
+				continue;
+			}
+
+			queryId = pg_strtoint64_safe(queryid_str, (Node *) &escontext);
+			if (!SOFT_ERROR_OCCURRED(&escontext))
+			{
+				pgsa_set_advice_string(stash_name, queryId, advice_string);
+				num_entries++;
+			}
+			else
+				num_malformed++;
+
+			pfree(stash_name);
+			pfree(queryid_str);
+			pfree(advice_string);
+		}
+		else
+		{
+			num_malformed++;
+		}
+		pfree(line_type);
+		pfree(line);
+	}
+
+	FreeFile(file);
+
+	if (num_malformed > 0)
+		ereport(WARNING,
+				errmsg("skipped %d malformed advice lines on load",
+					   num_malformed));
+
+	ereport(LOG,
+			errmsg("loaded %d advice stashes with %d entries",
+				   num_stashes, num_entries));
+}
+
+/*
+ * Backslash-escape the string so it can be written to a tab-separated file.
+ *
+ * The escaped characters are backslash, tab, and newline.
+ */
+static char *
+pgsa_escape_string(char *str)
+{
+	StringInfoData buf;
+
+	if (!strpbrk(str, "\\\t\n"))
+		return str;
+
+	initStringInfo(&buf);
+	for (const char *p = str; *p; p++)
+	{
+		switch (*p)
+		{
+			case '\\':
+				appendStringInfoString(&buf, "\\\\");
+				break;
+			case '\t':
+				appendStringInfoString(&buf, "\\t");
+				break;
+			case '\n':
+				appendStringInfoString(&buf, "\\n");
+				break;
+			case '\r':
+				appendStringInfoString(&buf, "\\r");
+				break;
+			default:
+				appendStringInfoChar(&buf, *p);
+				break;
+		}
+	}
+
+	return buf.data;
+}
+
+/*
+ * Read the next tab-delimited field from *pp, unescaping backslash sequences
+ * as we go.  Advances *pp past the tab delimiter (or to end of string).
+ *
+ * Returns a palloc'd string with the unescaped field value, or NULL if there
+ * are no more fields (i.e. *pp already points to '\0').
+ */
+static char *
+pgsa_read_next_field(char **pp)
+{
+	StringInfoData buf;
+	const char *p = *pp;
+
+	if (*p == '\0')
+		return NULL;
+
+	initStringInfo(&buf);
+	while (*p != '\0' && *p != '\t')
+	{
+		if (*p == '\\' && p[1] != '\0')
+		{
+			p++;
+			switch (*p)
+			{
+				case '\\':
+					appendStringInfoChar(&buf, '\\');
+					break;
+				case 't':
+					appendStringInfoChar(&buf, '\t');
+					break;
+				case 'n':
+					appendStringInfoChar(&buf, '\n');
+					break;
+				case 'r':
+					appendStringInfoChar(&buf, '\r');
+					break;
+				default:
+					/* Unrecognized escape; keep as-is. */
+					appendStringInfoChar(&buf, *p);
+					break;
+			}
+		}
+		else
+			appendStringInfoChar(&buf, *p);
+		p++;
+	}
+
+	/* Skip the tab delimiter if present. */
+	if (*p == '\t')
+		p++;
+
+	*pp = (char *) p;
+	return buf.data;
+}
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
index 089fc66446f..937d31e557b 100644
--- a/doc/src/sgml/pgstashadvice.sgml
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -15,10 +15,12 @@
   <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
   strings. Whenever a session is asked to plan a query whose query ID appears
   in the relevant advice stash, the plan advice string is automatically applied
-  to guide planning. Note that advice stashes exist purely in memory. This
-  means both that it is important to be mindful of memory consumption when
-  deciding how much plan advice to stash, and also that advice stashes must
-  be recreated and repopulated whenever the server is restarted.
+  to guide planning. Advice stashes are held in memory, so it is important
+  to be mindful of memory consumption when deciding how much plan advice to
+  stash. The contents are automatically saved to a file called
+  <filename>pg_stash_advice.tsv</filename> whenever they are modified,
+  and restored when the first session attaches after a server restart
+  (including after a crash).
  </para>
 
  <para>
@@ -203,6 +205,27 @@
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.save</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.save</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies whether to save advice stash contents to disk so that they
+      can be restored after a server restart (including after a crash).
+      When enabled, a background worker checks every 60 seconds for changes
+      and writes stash contents to a file called
+      <filename>pg_stash_advice.tsv</filename> in the data directory.
+      The default value is <literal>on</literal>.  This parameter can only
+      be set at server start.
+     </para>
+    </listitem>
+   </varlistentry>
+
   </variablelist>
 
  </sect2>
-- 
2.47.1



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-01 06:33  Lukas Fittl <[email protected]>
  parent: Lukas Fittl <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-04-01 06:33 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Tue, Mar 31, 2026 at 7:25 PM Lukas Fittl <[email protected]> wrote:
>
> On Mon, Mar 30, 2026 at 7:53 AM Robert Haas <[email protected]> wrote:
> >
> >
> > The module doesn't have a built-in way to do that right now. Are you
> > thinking we would document that pg_get_dsm_registry_allocations() can
> > be used?
>
> Yeah, for example. Alternatively we could provide a function/view that
> lists all advice across all stashes, so you can more easily see the
> result size of that and estimate what the in-memory use is. But
> pointing to pg_get_dsm_registry_allocations seems easier.

Actually, that won't work in practice with the code as of v23 -
pg_get_dsm_registry_allocations() always returns the fixed 64 byte
allocation from GetNamedDSMSegment, but is oblivious to the individual
DSA allocations (even after adding hundreds of entries):

SELECT * FROM pg_get_dsm_registry_allocations();

      name       |  type   | size
-----------------+---------+------
 pg_stash_advice | segment |   64
(1 row)

Is there a reason you didn't use GetNamedDSA / GetNamedDSHash for the
other allocations? (which we have as of fe07100e82b09)

With the adjustments in the attached patch, this gets reported as expected:

SELECT * FROM pg_get_dsm_registry_allocations();

         name          |  type   |   size
-----------------------+---------+-----------
 pg_stash_advice       | segment |        24
 pg_stash_advice_stash | hash    |   1048576
 pg_stash_advice_dsa   | area    | 803209216
 pg_stash_advice_entry | hash    |   1048576
(4 rows)

>
> > > In practice for a good amount of our user base these days the question
> > > will be "Does my cloud provider give me access to create stash
> > > entries", so its maybe worth thinking about if we could also allow
> > > pg_maintain to manage entries by default?
> >
> > Wouldn't it make more sense for the cloud provider to grant execute
> > permissions on these functions to pg_maintain if they are so inclined?
> > This is a brand-new facility, so I think we had better be conservative
> > in terms of default permissions.
>
> I guess. I'm always worried that providers get that wrong and forget
> to give end users the permissions - but I suppose end users can
> complain to their providers if that's the case.
>
> I've done another look over pg_set_stashed_advice and I think its in
> good shape. The only trailing thought I have is that we could consider
> running a fuzzer against the pg_set_advice function in particular,
> just to see if anything pops up (beyond having the ability to make a
> very large memory allocation through a large advice string, which is
> maybe a problem?).

Obviously I meant "I've done another look over pg_stash_advice and I
think its in good shape".

I've done some basic fuzzing with the pg_set_stashed_advice function,
including concurrently setting advice, and that didn't yield any
surprises.

Thanks,
Lukas

-- 
Lukas Fittl


Attachments:

  [application/octet-stream] nocfbot-3-0001-Use-GetNamedDSA-GetNamedDSHash-for-shared.patch (5.7K, 2-nocfbot-3-0001-Use-GetNamedDSA-GetNamedDSHash-for-shared.patch)
  download | inline diff:
From a9de0367fc504de403b93be179f36615a3884fc7 Mon Sep 17 00:00:00 2001
From: Lukas Fittl <[email protected]>
Date: Tue, 31 Mar 2026 23:30:11 -0700
Subject: [PATCH vnocfbot-3] Use GetNamedDSA / GetNamedDSHash for shared memory
 allocations

This allows utilizing pg_get_dsm_registry_allocations to see the amount
of memory used by the internal storage.
---
 contrib/pg_stash_advice/pg_stash_advice.c | 108 +++-------------------
 1 file changed, 11 insertions(+), 97 deletions(-)

diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
index 22122236694..c74d0eebf45 100644
--- a/contrib/pg_stash_advice/pg_stash_advice.c
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -43,13 +43,7 @@ PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
 typedef struct pgsa_shared_state
 {
 	LWLock		lock;
-	int			dsa_tranche;
-	int			stash_tranche;
-	int			entry_tranche;
 	uint64		next_stash_id;
-	dsa_handle	area;
-	dshash_table_handle stash_hash;
-	dshash_table_handle entry_hash;
 } pgsa_shared_state;
 
 typedef struct pgsa_stash
@@ -113,29 +107,27 @@ static dshash_table *pgsa_stash_dshash;
 static dshash_table *pgsa_entry_dshash;
 
 /* Shared memory hash table parameters */
-static dshash_parameters pgsa_stash_dshash_parameters = {
+static const dshash_parameters pgsa_stash_dshash_parameters = {
 	NAMEDATALEN,
 	sizeof(pgsa_stash),
 	dshash_strcmp,
 	dshash_strhash,
 	dshash_strcpy,
-	LWTRANCHE_INVALID			/* gets set at runtime */
+	LWTRANCHE_INVALID			/* assigned by GetNamedDSHash */
 };
 
-static dshash_parameters pgsa_entry_dshash_parameters = {
+static const dshash_parameters pgsa_entry_dshash_parameters = {
 	sizeof(pgsa_entry_key),
 	sizeof(pgsa_entry),
 	dshash_memcmp,
 	dshash_memhash,
 	dshash_memcpy,
-	LWTRANCHE_INVALID			/* gets set at runtime */
+	LWTRANCHE_INVALID			/* assigned by GetNamedDSHash */
 };
 
 /* GUC variable */
 static char *pg_stash_advice_stash_name = "";
 
-/* Other global variables */
-static MemoryContext pg_stash_advice_mcxt;
 
 /* Function prototypes */
 static char *pgsa_advisor(PlannerGlobal *glob,
@@ -519,17 +511,6 @@ static void
 pgsa_attach(void)
 {
 	bool		found;
-	MemoryContext oldcontext;
-
-	/*
-	 * Create a memory context to make sure that any control structures
-	 * allocated in local memory are sufficiently persistent.
-	 */
-	if (pg_stash_advice_mcxt == NULL)
-		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
-													 "pg_stash_advice",
-													 ALLOCSET_DEFAULT_SIZES);
-	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
 
 	/* Attach to the fixed-size state object if not already done. */
 	if (pgsa_state == NULL)
@@ -540,80 +521,19 @@ pgsa_attach(void)
 
 	/* Attach to the DSA area if not already done. */
 	if (pgsa_dsa_area == NULL)
-	{
-		dsa_handle	area_handle;
-
-		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
-		area_handle = pgsa_state->area;
-		if (area_handle == DSA_HANDLE_INVALID)
-		{
-			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
-			dsa_pin(pgsa_dsa_area);
-			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
-			LWLockRelease(&pgsa_state->lock);
-		}
-		else
-		{
-			LWLockRelease(&pgsa_state->lock);
-			pgsa_dsa_area = dsa_attach(area_handle);
-		}
-		dsa_pin_mapping(pgsa_dsa_area);
-	}
+		pgsa_dsa_area = GetNamedDSA("pg_stash_advice_dsa", &found);
 
 	/* Attach to the stash_name->stash_id hash table if not already done. */
 	if (pgsa_stash_dshash == NULL)
-	{
-		dshash_table_handle stash_handle;
-
-		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
-		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
-		stash_handle = pgsa_state->stash_hash;
-		if (stash_handle == DSHASH_HANDLE_INVALID)
-		{
-			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
-											  &pgsa_stash_dshash_parameters,
-											  NULL);
-			pgsa_state->stash_hash =
-				dshash_get_hash_table_handle(pgsa_stash_dshash);
-			LWLockRelease(&pgsa_state->lock);
-		}
-		else
-		{
-			LWLockRelease(&pgsa_state->lock);
-			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
-											  &pgsa_stash_dshash_parameters,
-											  stash_handle, NULL);
-		}
-	}
+		pgsa_stash_dshash = GetNamedDSHash("pg_stash_advice_stash",
+										   &pgsa_stash_dshash_parameters,
+										   &found);
 
 	/* Attach to the entry hash table if not already done. */
 	if (pgsa_entry_dshash == NULL)
-	{
-		dshash_table_handle entry_handle;
-
-		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
-		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
-		entry_handle = pgsa_state->entry_hash;
-		if (entry_handle == DSHASH_HANDLE_INVALID)
-		{
-			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
-											  &pgsa_entry_dshash_parameters,
-											  NULL);
-			pgsa_state->entry_hash =
-				dshash_get_hash_table_handle(pgsa_entry_dshash);
-			LWLockRelease(&pgsa_state->lock);
-		}
-		else
-		{
-			LWLockRelease(&pgsa_state->lock);
-			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
-											  &pgsa_entry_dshash_parameters,
-											  entry_handle, NULL);
-		}
-	}
-
-	/* Restore previous memory context. */
-	MemoryContextSwitchTo(oldcontext);
+		pgsa_entry_dshash = GetNamedDSHash("pg_stash_advice_entry",
+										   &pgsa_entry_dshash_parameters,
+										   &found);
 }
 
 /*
@@ -789,13 +709,7 @@ pgsa_init_shared_state(void *ptr, void *arg)
 
 	LWLockInitialize(&state->lock,
 					 LWLockNewTrancheId("pg_stash_advice_lock"));
-	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
-	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
-	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
 	state->next_stash_id = UINT64CONST(1);
-	state->area = DSA_HANDLE_INVALID;
-	state->stash_hash = DSHASH_HANDLE_INVALID;
-	state->entry_hash = DSHASH_HANDLE_INVALID;
 }
 
 /*
-- 
2.47.1



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-02 16:15  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-02 16:15 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Tue, Mar 31, 2026 at 10:25 PM Lukas Fittl <[email protected]> wrote:
> To be clear, I think its okay to go ahead with merging pg_stash_advice
> without that and make it a best effort to get the file saving in too -
> but I think with the current design in this patch represents a
> reasonable solution to what we can do in terms of persistence across
> restarts in either 19 or 20.

Somewhat against my better judgement, I have attempted to put your
patch into committable shape. In the process, I rewrote pretty much
the whole thing.  So here's v24, also dropping pg_collect_advice.

0001 is the pg_stash_advice patch from v23, but with a number of
changes motivated by your desire to add persistence. I split the code
into two files, because I felt that file was starting to get a little
bit large, and I didn't want to just add a whole bunch more stuff to
it. For the most part, this is just code movement, but I did make a
couple of substantive changes. First, I restricted stash names to
alphanumeric characters and underscores, basically looking like
identifier names. This is partly because I got thinking about the
escaping requirements for the persistence file, but it's also because
I realized that letting somebody name their stashes with spaces or
non-printable characters in the name or a bunch of random punctuation
was probably more confusing than useful. Second,
pgsa_set_advice_string() was previously taking a lock itself, but most
of its sister functions require the caller to do that; I changed it to
match. Third, lock is also now held when calling
pgsa_clear_advice_string(), which may not be entirely necessary, but
it seems safer and shouldn't cost anything meaningful.

0002 adds persistence. Here's a list of changes from your version:

- I changed the GUC name pg_stash_advice.save to
pg_stash_advice.persist, since it controls both whether advice is
saved automatically and also whether it's loaded automatically at
startup time.

- I added a GUC pg_stash_advice.persist_interval, so that the interval
between writes can be configured.

- Instead of a dump_requested flag, I added a pg_atomic_uint64
change_count. This avoids needing to take &pgsa_state->lock in
LW_EXCLUSIVE mode. Even leaving that aside, I don't think a Boolean is
adequate here. Your patch cleared the flag before dumping, but that
means if the act of dumping fails, you forget that it needed to be
done. If you instead clear the flag after dumping, then you don't
realize you need to do it again if any concurrent changes happen.

- I set the restart time to BGW_DEFAULT_RESTART_INTERVAL rather than
0. Restarting the worker in a tight loop is a bad plan.

- I added a function pg_start_stash_advice_worker(). You could do
something like add pg_stash_advice to session_preload_libraries, start
using it, and use this to kick off the worker. Then eventually you can
restart the server with pg_stash_advice moved to
shared_preload_libraries.

- As you had coded it, any interrupt that jostled the worker would
trigger an immediate write-out if one was pending. That behavior seems
hard to explain and document, so I made it work more like autoprewarm,
which always respects the configured interval even in case of
interrupts.

- I added a mechanism to prevent the user from manually manipulating
stashes or stash entries when persistence is enabled but before the
dump file has been reloaded. Without this, reloading the dump file
could error out if, for example, the user already managed to recreate
a stash with the same name as one that exists in the dump file.

- As you had coded it, data is inserted into the dynamic shared memory
structures as it's read from the disk file. I felt that could produce
rather odd behavior, especially in view of the lack of the lockout
mechanism mentioned in the previous point. We might partially process
the dump file and then die with some data loaded and other data not
loaded. Other backends could see the partial results. While the
lockout mechanism by itself is sufficient to prevent that, I felt
uncomfortable about relying on that completely. It means we start
consuming shared memory even before we know whether there's an error,
and continue to consume it after we've died from an error, and it also
means we have a very hard dependency on the lockout mechanism, which
really only works if there's only ever one load and save file and
loading only happens at startup time. I felt it was better to slurp
all the data into memory first, parse it completely, and then start
making changes to shared memory only if we don't find any problems, so
I made it work that way. We replace the tabs and newlines that end
fields and lines with \0 on the fly so that we can just point into
that buffer, instead of having to pstrdup() anything. (Note that, even
if we stuck with your approach of something based on pg_get_line(), it
would probably be better to use one of the other variants, e.g.
pg_get_line_buf(), to avoid allocating new buffers constantly.)

- I completely reworked the string escaping. Your pgsa_escape_string()
had a bug where the strpbrk call didn't check for \r. Also, I didn't
like the behavior of just ignoring a backslash when it was followed by
end of string or something unexpected; I felt those should be errors.
Given the decision to slurp the whole file, as mentioned in the
previous point, it also made sense to do the unescaping in place, so
that we didn't need to allocate additional memory. I particularly
didn't like the decision to sometimes allocate memory and sometimes
not. While it was economical in a sense, it meant that the memory
consumption could be very different depending on how many entries
needed escaping.

- I completely reworked the error reporting. Now, if we hit an error
parsing the file (or doing anything else), we just signal an ERROR,
and we rely on the fact that the postmaster will restart us. It's an
explicit goal not to apply incremental changes when the file overall
is not valid, which also means that we are only concerned about
reporting the first error that we detect, which also seems good for
avoiding log spam. On the other hand, the error reports are more
specific and detailed, and now all follow the same general pattern:
"syntax error in file \"%s\" line %u: problem description here". (I
was also not entirely happy with the fact that you could potentially
call fprintf() lots of times before finally reporting an error. While
you did save and restore errno around the calls to FreeFile() and
unlink(), I think it makes the code hard to reason about; e.g. what if
a later fprintf call hit a different error than an earlier one?)

- I felt it was a bit odd to install a zero length file, so I made the
persistence mechanism remove the existing file if there's nothing to
persist when it goes to write out. I am not totally sure this was the
right call.

- I added a message when saving entries symmetrical to the one you had
for loading entries, and also some DEBUG1/2 messages in case someone
needs more details.

- I added a TAP test. This isn't as comprehensive as it could be -- in
particular, it doesn't cover all the possible error cases that occur
when trying to reload data from disk. I could add that, but it would
mean stopping and restarting the server a bunch of times, and I wasn't
sure that it was a good idea to add that much overhead for a few lines
of code coverage.

- Your documentation changes still said that the stash data would be
"restored when the first session attaches after a server restart" but
that doesn't sound like something that a user will understand, and
also wasn't what actually happened since you added the background
worker. I rewrote this.

There's almost none of your code remaining at this point, but I listed
you as a co-author for 0002. I think a case could be made for Author
or for no listing. Let me know if you have an opinion. And, please
review!

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v24-0002-pg_stash_advice-Allow-stashed-advice-to-be-persi.patch (45.9K, 2-v24-0002-pg_stash_advice-Allow-stashed-advice-to-be-persi.patch)
  download | inline diff:
From 9b3690f9dc86f9d30dbb27f53088cfe5dd8fc81a Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Thu, 2 Apr 2026 10:13:07 -0400
Subject: [PATCH v24 2/2] pg_stash_advice: Allow stashed advice to be persisted
 to disk.

If pg_stash_advice.persist = true, stashed advice will be written to
pg_stash_advice.tsv in the data directory, periodically and at
shutdown. On restart, stash modifications are locked out until this
file has been reloaded, but queries will not be, so there may be a
short window after startup during which previously-stashed advice is
not automatically applied.

Author: Robert Haas <[email protected]>
Co-authored-by: <[email protected]>
---
 contrib/pg_stash_advice/Makefile              |   4 +-
 contrib/pg_stash_advice/meson.build           |   8 +-
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |   6 +
 contrib/pg_stash_advice/pg_stash_advice.c     | 168 +++-
 contrib/pg_stash_advice/pg_stash_advice.h     |  12 +
 contrib/pg_stash_advice/stashfuncs.c          |  40 +
 contrib/pg_stash_advice/stashpersist.c        | 799 ++++++++++++++++++
 contrib/pg_stash_advice/t/001_persist.pl      |  84 ++
 doc/src/sgml/pgstashadvice.sgml               |  70 +-
 src/tools/pgindent/typedefs.list              |   4 +
 10 files changed, 1187 insertions(+), 8 deletions(-)
 create mode 100644 contrib/pg_stash_advice/stashpersist.c
 create mode 100644 contrib/pg_stash_advice/t/001_persist.pl

diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
index f5150e14e41..ec46f5db0b7 100644
--- a/contrib/pg_stash_advice/Makefile
+++ b/contrib/pg_stash_advice/Makefile
@@ -4,13 +4,15 @@ MODULE_big = pg_stash_advice
 OBJS = \
 	$(WIN32RES) \
 	pg_stash_advice.o \
-	stashfuncs.o
+	stashfuncs.o \
+	stashpersist.o
 
 EXTENSION = pg_stash_advice
 DATA = pg_stash_advice--1.0.sql
 PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
 
 REGRESS = pg_stash_advice
+TAP_TESTS = 1
 EXTRA_INSTALL = contrib/pg_plan_advice
 
 ifdef USE_PGXS
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
index 6655f9ab4f2..813b8fe3c40 100644
--- a/contrib/pg_stash_advice/meson.build
+++ b/contrib/pg_stash_advice/meson.build
@@ -2,7 +2,8 @@
 
 pg_stash_advice_sources = files(
   'pg_stash_advice.c',
-  'stashfuncs.c'
+  'stashfuncs.c',
+  'stashpersist.c'
 )
 
 if host_system == 'windows'
@@ -33,4 +34,9 @@ tests += {
       'pg_stash_advice',
     ],
   },
+  'tap': {
+    'tests': [
+      't/001_persist.pl',
+    ],
+  },
 }
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
index 88dedd8ef1b..50f12dac313 100644
--- a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -36,8 +36,14 @@ RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
 LANGUAGE C;
 
+CREATE FUNCTION pg_start_stash_advice_worker()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_start_stash_advice_worker'
+LANGUAGE C STRICT;
+
 REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
 REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
 REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
 REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
 REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_start_stash_advice_worker() FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
index 15e7adf849b..605f8c4f23d 100644
--- a/contrib/pg_stash_advice/pg_stash_advice.c
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -13,9 +13,11 @@
 
 #include "common/hashfn.h"
 #include "common/string.h"
+#include "miscadmin.h"
 #include "nodes/queryjumble.h"
 #include "pg_plan_advice.h"
 #include "pg_stash_advice.h"
+#include "postmaster/bgworker.h"
 #include "storage/dsm_registry.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
@@ -41,12 +43,14 @@ static dshash_parameters pgsa_entry_dshash_parameters = {
 	LWTRANCHE_INVALID			/* gets set at runtime */
 };
 
-/* GUC variable */
+/* GUC variables */
 static char *pg_stash_advice_stash_name = "";
+bool		pg_stash_advice_persist = true;
+int			pg_stash_advice_persist_interval = 30;
 
 /* Shared memory pointers */
 pgsa_shared_state *pgsa_state;
-dsa_area *pgsa_dsa_area;
+dsa_area   *pgsa_dsa_area;
 dshash_table *pgsa_stash_dshash;
 dshash_table *pgsa_entry_dshash;
 
@@ -87,6 +91,33 @@ _PG_init(void)
 	EnableQueryId();
 
 	/* Define our GUCs. */
+	if (process_shared_preload_libraries_in_progress)
+		DefineCustomBoolVariable("pg_stash_advice.persist",
+								 "Save and restore advice stash contents across restarts.",
+								 NULL,
+								 &pg_stash_advice_persist,
+								 true,
+								 PGC_POSTMASTER,
+								 0,
+								 NULL,
+								 NULL,
+								 NULL);
+	else
+		pg_stash_advice_persist = false;
+
+	DefineCustomIntVariable("pg_stash_advice.persist_interval",
+							"Interval between advice stash saves, in seconds.",
+							NULL,
+							&pg_stash_advice_persist_interval,
+							30,
+							0,
+							3600,
+							PGC_SIGHUP,
+							GUC_UNIT_S,
+							NULL,
+							NULL,
+							NULL);
+
 	DefineCustomStringVariable("pg_stash_advice.stash_name",
 							   "Name of the advice stash to be used in this session.",
 							   NULL,
@@ -100,6 +131,10 @@ _PG_init(void)
 
 	MarkGUCPrefixReserved("pg_stash_advice");
 
+	/* Start the background worker for persistence, if enabled. */
+	if (pg_stash_advice_persist)
+		pgsa_start_worker();
+
 	/* Tell pg_plan_advice that we want to provide advice strings. */
 	add_advisor_fn =
 		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
@@ -131,6 +166,10 @@ pgsa_advisor(PlannerGlobal *glob, Query *parse,
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
 
+	/* If stash data is still being restored from disk, ignore. */
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+		return NULL;
+
 	/*
 	 * Translate pg_stash_advice.stash_name to an integer ID.
 	 *
@@ -279,6 +318,19 @@ pgsa_attach(void)
 	MemoryContextSwitchTo(oldcontext);
 }
 
+/*
+ * Error out if the stashes have not been loaded from disk yet.
+ */
+void
+pgsa_check_lockout(void)
+{
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("stash modifications are not allowed because \"%s\" has not been loaded yet",
+						PGSA_DUMP_FILE)));
+}
+
 /*
  * Check whether an advice stash name is legal, and signal an error if not.
  *
@@ -383,6 +435,9 @@ pgsa_create_stash(char *stash_name)
 				errmsg("advice stash \"%s\" already exists", stash_name));
 	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
 	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	/* Bump change count. */
+	pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
 }
 
 /*
@@ -423,6 +478,9 @@ pgsa_clear_advice_string(char *stash_name, int64 queryId)
 	/* Now we free the advice string as well, if there was one. */
 	if (old_dp != InvalidDsaPointer)
 		dsa_free(pgsa_dsa_area, old_dp);
+
+	/* Bump change count. */
+	pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
 }
 
 /*
@@ -464,6 +522,43 @@ pgsa_drop_stash(char *stash_name)
 		}
 	}
 	dshash_seq_term(&iterator);
+
+	/* Bump change count. */
+	pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
+}
+
+/*
+ * Remove all stashes and entries from shared memory.
+ *
+ * This is intended to be called before reloading from a dump file, so that
+ * a failed previous attempt doesn't leave stale data behind.
+ */
+void
+pgsa_reset_all_stashes(void)
+{
+	dshash_seq_status iter;
+	pgsa_entry *entry;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove all stashes. */
+	dshash_seq_init(&iter, pgsa_stash_dshash, true);
+	while (dshash_seq_next(&iter) != NULL)
+		dshash_delete_current(&iter);
+	dshash_seq_term(&iter);
+
+	/* Remove all entries. */
+	dshash_seq_init(&iter, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iter)) != NULL)
+	{
+		if (entry->advice_string != InvalidDsaPointer)
+			dsa_free(pgsa_dsa_area, entry->advice_string);
+		dshash_delete_current(&iter);
+	}
+	dshash_seq_term(&iter);
+
+	/* Reset the stash ID counter. */
+	pgsa_state->next_stash_id = UINT64CONST(1);
 }
 
 /*
@@ -483,6 +578,23 @@ pgsa_init_shared_state(void *ptr, void *arg)
 	state->area = DSA_HANDLE_INVALID;
 	state->stash_hash = DSHASH_HANDLE_INVALID;
 	state->entry_hash = DSHASH_HANDLE_INVALID;
+	state->bgworker_pid = InvalidPid;
+	pg_atomic_init_flag(&state->stashes_ready);
+	pg_atomic_init_u64(&state->change_count, 0);
+
+	/*
+	 * If this module was loaded via shared_preload_libraries, then
+	 * pg_stash_advice_persist is a GUC variable. If it's true, that means
+	 * that we should lock out manual stash modifications until the dump file
+	 * has been successfully loaded. If it's false, there's nothing to load,
+	 * so we set stashes_ready immediately.
+	 *
+	 * If this module was not loaded via shared_preload_libraries, then
+	 * pg_stash_advice_persist is not a GUC variable, but it will be false,
+	 * which leads to the correct behavior.
+	 */
+	if (!pg_stash_advice_persist)
+		pg_atomic_test_set_flag(&state->stashes_ready);
 }
 
 /*
@@ -602,4 +714,56 @@ pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
 	 */
 	if (DsaPointerIsValid(old_dp))
 		dsa_free(pgsa_dsa_area, old_dp);
+
+	/* Bump change count. */
+	pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
+}
+
+/*
+ * Start our worker process.
+ */
+void
+pgsa_start_worker(void)
+{
+	BackgroundWorker worker = {0};
+	BackgroundWorkerHandle *handle;
+	BgwHandleStatus status;
+	pid_t		pid;
+
+	worker.bgw_flags = BGWORKER_SHMEM_ACCESS;
+	worker.bgw_start_time = BgWorkerStart_ConsistentState;
+	worker.bgw_restart_time = BGW_DEFAULT_RESTART_INTERVAL;
+	strcpy(worker.bgw_library_name, "pg_stash_advice");
+	strcpy(worker.bgw_function_name, "pg_stash_advice_worker_main");
+	strcpy(worker.bgw_name, "pg_stash_advice worker");
+	strcpy(worker.bgw_type, "pg_stash_advice worker");
+
+	/*
+	 * If this is the postmaster, we can directly register the background
+	 * worker. (This could be reached even after shared_preload_libraries, but
+	 * it will just fail in that case. It's not worth the code to give a nicer
+	 * error.)
+	 */
+	if (!IsUnderPostmaster)
+	{
+		RegisterBackgroundWorker(&worker);
+		return;
+	}
+
+	/*
+	 * We've not in the postmaster, so use RegisterDynamicBackgroundWorker,
+	 * and then wait for startup to complete.
+	 */
+	worker.bgw_notify_pid = MyProcPid;
+	if (!RegisterDynamicBackgroundWorker(&worker, &handle))
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_RESOURCES),
+				 errmsg("could not register background process"),
+				 errhint("You may need to increase \"max_worker_processes\".")));
+	status = WaitForBackgroundWorkerStartup(handle, &pid);
+	if (status != BGWH_STARTED)
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_RESOURCES),
+				 errmsg("could not start background process"),
+				 errhint("More details may be available in the server log.")));
 }
diff --git a/contrib/pg_stash_advice/pg_stash_advice.h b/contrib/pg_stash_advice/pg_stash_advice.h
index eeaa61e0f37..01aded472f3 100644
--- a/contrib/pg_stash_advice/pg_stash_advice.h
+++ b/contrib/pg_stash_advice/pg_stash_advice.h
@@ -22,6 +22,8 @@
 #include "lib/dshash.h"
 #include "storage/lwlock.h"
 
+#define PGSA_DUMP_FILE		"pg_stash_advice.tsv"
+
 /*
  * The key that we use to find a particular stash entry.
  */
@@ -62,6 +64,9 @@ typedef struct pgsa_shared_state
 	dsa_handle	area;
 	dshash_table_handle stash_hash;
 	dshash_table_handle entry_hash;
+	pid_t		bgworker_pid;
+	pg_atomic_flag stashes_ready;
+	pg_atomic_uint64 change_count;
 } pgsa_shared_state;
 
 /* For stash ID -> stash name hash table */
@@ -86,14 +91,21 @@ extern dsa_area *pgsa_dsa_area;
 extern dshash_table *pgsa_stash_dshash;
 extern dshash_table *pgsa_entry_dshash;
 
+/* GUC variables */
+extern bool pg_stash_advice_persist;
+extern int	pg_stash_advice_persist_interval;
+
 /* Function prototypes */
 extern void pgsa_attach(void);
+extern void pgsa_check_lockout(void);
 extern void pgsa_check_stash_name(char *stash_name);
 extern void pgsa_clear_advice_string(char *stash_name, int64 queryId);
 extern void pgsa_create_stash(char *stash_name);
 extern void pgsa_drop_stash(char *stash_name);
 extern uint64 pgsa_lookup_stash_id(char *stash_name);
+extern void pgsa_reset_all_stashes(void);
 extern void pgsa_set_advice_string(char *stash_name, int64 queryId,
 								   char *advice_string);
+extern void pgsa_start_worker(void);
 
 #endif
diff --git a/contrib/pg_stash_advice/stashfuncs.c b/contrib/pg_stash_advice/stashfuncs.c
index d8c669d6ab7..77f8e19e867 100644
--- a/contrib/pg_stash_advice/stashfuncs.c
+++ b/contrib/pg_stash_advice/stashfuncs.c
@@ -14,6 +14,7 @@
 #include "common/hashfn.h"
 #include "fmgr.h"
 #include "funcapi.h"
+#include "miscadmin.h"
 #include "pg_stash_advice.h"
 #include "utils/builtins.h"
 #include "utils/tuplestore.h"
@@ -23,6 +24,7 @@ PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
 PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
 PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
 PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+PG_FUNCTION_INFO_V1(pg_start_stash_advice_worker);
 
 typedef struct pgsa_stash_count
 {
@@ -53,6 +55,7 @@ pg_create_advice_stash(PG_FUNCTION_ARGS)
 	pgsa_check_stash_name(stash_name);
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
+	pgsa_check_lockout();
 	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
 	pgsa_create_stash(stash_name);
 	LWLockRelease(&pgsa_state->lock);
@@ -70,6 +73,7 @@ pg_drop_advice_stash(PG_FUNCTION_ARGS)
 	pgsa_check_stash_name(stash_name);
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
+	pgsa_check_lockout();
 	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
 	pgsa_drop_stash(stash_name);
 	LWLockRelease(&pgsa_state->lock);
@@ -94,6 +98,10 @@ pg_get_advice_stashes(PG_FUNCTION_ARGS)
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
 
+	/* If stash data is still being restored from disk, ignore. */
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+		return (Datum) 0;
+
 	/* Tally up the number of entries per stash. */
 	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
 	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
@@ -154,6 +162,10 @@ pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
 
+	/* If stash data is still being restored from disk, ignore. */
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+		return (Datum) 0;
+
 	/* User can pass NULL for all stashes, or the name of a specific stash. */
 	if (!PG_ARGISNULL(0))
 	{
@@ -286,6 +298,9 @@ pg_set_stashed_advice(PG_FUNCTION_ARGS)
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
 
+	/* Don't allow writes if stash data is still being restored from disk. */
+	pgsa_check_lockout();
+
 	/* Now call the appropriate function to do the real work. */
 	if (PG_ARGISNULL(2))
 	{
@@ -305,3 +320,28 @@ pg_set_stashed_advice(PG_FUNCTION_ARGS)
 	PG_RETURN_VOID();
 }
 
+/*
+ * SQL-callable function to start the persistence background worker.
+ */
+Datum
+pg_start_stash_advice_worker(PG_FUNCTION_ARGS)
+{
+	pid_t		pid;
+
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+	pid = pgsa_state->bgworker_pid;
+	LWLockRelease(&pgsa_state->lock);
+
+	if (pid != InvalidPid)
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("pg_stash_advice worker is already running under PID %d",
+						(int) pid)));
+
+	pgsa_start_worker();
+
+	PG_RETURN_VOID();
+}
diff --git a/contrib/pg_stash_advice/stashpersist.c b/contrib/pg_stash_advice/stashpersist.c
new file mode 100644
index 00000000000..da96ee0d803
--- /dev/null
+++ b/contrib/pg_stash_advice/stashpersist.c
@@ -0,0 +1,799 @@
+/*-------------------------------------------------------------------------
+ *
+ * stashpersist.c
+ *	  Persistence support for pg_stash_advice.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/stashpersist.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <sys/stat.h>
+
+#include "common/hashfn.h"
+#include "miscadmin.h"
+#include "pg_stash_advice.h"
+#include "postmaster/bgworker.h"
+#include "postmaster/interrupt.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "storage/latch.h"
+#include "storage/proc.h"
+#include "storage/procsignal.h"
+#include "utils/backend_status.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+#include "utils/timestamp.h"
+
+typedef struct pgsa_writer_context
+{
+	char		pathname[MAXPGPATH];
+	FILE	   *file;
+	pgsa_stash_name_table_hash *nhash;
+	StringInfoData buf;
+	int			entries_written;
+} pgsa_writer_context;
+
+/*
+ * A parsed entry line, with pointers into the slurp buffer.
+ */
+typedef struct pgsa_saved_entry
+{
+	char	   *stash_name;
+	int64		queryId;
+	char	   *advice_string;
+} pgsa_saved_entry;
+
+/*
+ * simplehash for detecting duplicate stash names during parsing.
+ * Keyed by stash name (char *), pointing into the slurp buffer.
+ */
+typedef struct pgsa_saved_stash
+{
+	uint32		status;
+	char	   *name;
+} pgsa_saved_stash;
+
+#define SH_PREFIX pgsa_saved_stash_table
+#define SH_ELEMENT_TYPE pgsa_saved_stash
+#define SH_KEY_TYPE char *
+#define SH_KEY name
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) (key), strlen(key))
+#define SH_EQUAL(tb, a, b) (strcmp(a, b) == 0)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+extern PGDLLEXPORT void pg_stash_advice_worker_main(Datum main_arg);
+static void pgsa_append_tsv_escaped_string(StringInfo buf, const char *str);
+static void pgsa_detach_shmem(int code, Datum arg);
+static char *pgsa_next_tsv_field(char **cursor);
+static void pgsa_read_from_disk(void);
+static void pgsa_restore_entries(pgsa_saved_entry *entries, int num_entries);
+static void pgsa_restore_stashes(pgsa_saved_stash_table_hash *saved_stashes);
+static void pgsa_unescape_tsv_field(char *str, const char *filename,
+									unsigned lineno);
+static void pgsa_write_entries(pgsa_writer_context *wctx);
+pg_noreturn static void pgsa_write_error(pgsa_writer_context *wctx);
+static void pgsa_write_stashes(pgsa_writer_context *wctx);
+static void pgsa_write_to_disk(void);
+
+/*
+ * Background worker entry point for pg_stash_advice persistence.
+ *
+ * On startup, if load_from_disk_pending is set, we load previously saved
+ * stash data from disk.  Then we enter a loop, periodically checking whether
+ * any changes have been made (via the change_count atomic counter) and
+ * writing them to disk.  On shutdown, we perform a final write.
+ */
+PGDLLEXPORT void
+pg_stash_advice_worker_main(Datum main_arg)
+{
+	uint64		last_change_count;
+	TimestampTz last_write_time = 0;
+
+	/* Establish signal handlers; once that's done, unblock signals. */
+	pqsignal(SIGTERM, SignalHandlerForShutdownRequest);
+	pqsignal(SIGHUP, SignalHandlerForConfigReload);
+	pqsignal(SIGUSR1, procsignal_sigusr1_handler);
+	BackgroundWorkerUnblockSignals();
+
+	/* Log a debug message */
+	ereport(DEBUG1,
+			errmsg("pg_stash_advice worker started"));
+
+	/* Set up session user so pgstat can report it. */
+	InitializeSessionUserIdStandalone();
+
+	/* Report this worker in pg_stat_activity. */
+	pgstat_beinit();
+	pgstat_bestart_initial();
+	pgstat_bestart_final();
+
+	/* Attach to shared memory structures. */
+	pgsa_attach();
+
+	/* Set on-detach hook so that our PID will be cleared on exit. */
+	before_shmem_exit(pgsa_detach_shmem, 0);
+
+	/*
+	 * Store our PID in shared memory, unless there's already another worker
+	 * running, in which case just exit.
+	 */
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	if (pgsa_state->bgworker_pid != InvalidPid)
+	{
+		LWLockRelease(&pgsa_state->lock);
+		ereport(LOG,
+				(errmsg("pg_stash_advice worker is already running under PID %d",
+						(int) pgsa_state->bgworker_pid)));
+		return;
+	}
+	pgsa_state->bgworker_pid = MyProcPid;
+	LWLockRelease(&pgsa_state->lock);
+
+	/*
+	 * If pg_stash_advice.persist was set to true during
+	 * process_shared_preload_libraries() and the data has not yet been
+	 * successfully loaded, load it now.
+	 */
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+	{
+		pgsa_read_from_disk();
+		pg_atomic_test_set_flag(&pgsa_state->stashes_ready);
+	}
+
+	/* Note the current change count so we can detect future changes. */
+	last_change_count = pg_atomic_read_u64(&pgsa_state->change_count);
+
+	/* Periodically write to disk until terminated. */
+	while (!ShutdownRequestPending)
+	{
+		/* In case of a SIGHUP, just reload the configuration. */
+		if (ConfigReloadPending)
+		{
+			ConfigReloadPending = false;
+			ProcessConfigFile(PGC_SIGHUP);
+		}
+
+		if (pg_stash_advice_persist_interval <= 0)
+		{
+			/* Only writing at shutdown, so just wait forever. */
+			(void) WaitLatch(MyLatch,
+							 WL_LATCH_SET | WL_EXIT_ON_PM_DEATH,
+							 -1L,
+							 PG_WAIT_EXTENSION);
+		}
+		else
+		{
+			TimestampTz next_write_time;
+			long		delay_in_ms;
+			uint64		current_change_count;
+
+			/* Compute when the next write should happen. */
+			next_write_time =
+				TimestampTzPlusMilliseconds(last_write_time,
+											pg_stash_advice_persist_interval * 1000);
+			delay_in_ms =
+				TimestampDifferenceMilliseconds(GetCurrentTimestamp(),
+												next_write_time);
+
+			/*
+			 * When we reach next_write_time, we always update last_write_time
+			 * (which is really the time at which we last considered writing),
+			 * but we only actually write to disk if something has changed.
+			 */
+			if (delay_in_ms <= 0)
+			{
+				current_change_count =
+					pg_atomic_read_u64(&pgsa_state->change_count);
+				if (current_change_count != last_change_count)
+				{
+					pgsa_write_to_disk();
+					last_change_count = current_change_count;
+				}
+				last_write_time = GetCurrentTimestamp();
+				continue;
+			}
+
+			/* Sleep until the next write time. */
+			(void) WaitLatch(MyLatch,
+							 WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH,
+							 delay_in_ms,
+							 PG_WAIT_EXTENSION);
+		}
+
+		ResetLatch(MyLatch);
+	}
+
+	/* Write one last time before exiting. */
+	pgsa_write_to_disk();
+}
+
+/*
+ * Clear our PID from shared memory on exit.
+ */
+static void
+pgsa_detach_shmem(int code, Datum arg)
+{
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	if (pgsa_state->bgworker_pid == MyProcPid)
+		pgsa_state->bgworker_pid = InvalidPid;
+	LWLockRelease(&pgsa_state->lock);
+}
+
+/*
+ * Load advice stash data from a dump file on disk, if there is one.
+ */
+static void
+pgsa_read_from_disk(void)
+{
+	struct stat statbuf;
+	FILE	   *file;
+	char	   *filebuf;
+	size_t		nread;
+	char	   *p;
+	unsigned	lineno;
+	pgsa_saved_stash_table_hash *saved_stashes;
+	int			num_stashes = 0;
+	pgsa_saved_entry *entries;
+	int			num_entries = 0;
+	int			max_entries = 64;
+	MemoryContext tmpcxt;
+	MemoryContext oldcxt;
+
+	Assert(pgsa_entry_dshash != NULL);
+
+	/*
+	 * Clear any existing shared memory state.
+	 *
+	 * Normally, there won't be any, but if this function was called before
+	 * and failed after beginning to apply changes to shared memory, then we
+	 * need to get rid of any entries created at that time before trying
+	 * again.
+	 */
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_reset_all_stashes();
+	LWLockRelease(&pgsa_state->lock);
+
+	/* Open the dump file. If it doesn't exist, we're done. */
+	file = AllocateFile(PGSA_DUMP_FILE, "r");
+	if (!file)
+	{
+		if (errno == ENOENT)
+			return;
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not open file \"%s\": %m", PGSA_DUMP_FILE)));
+	}
+
+	/* Use a temporary context for all parse-phase allocations. */
+	tmpcxt = AllocSetContextCreate(CurrentMemoryContext,
+								   "pg_stash_advice load",
+								   ALLOCSET_DEFAULT_SIZES);
+	oldcxt = MemoryContextSwitchTo(tmpcxt);
+
+	/* Figure out how long the file is. */
+	if (fstat(fileno(file), &statbuf) != 0)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not stat file \"%s\": %m", PGSA_DUMP_FILE)));
+
+	/*
+	 * Slurp the entire file into memory all at once.
+	 *
+	 * We could avoid this by reading the file incrementally and applying
+	 * changes to pgsa_stash_dshash and pgsa_entry_dshash as we go. Given the
+	 * lockout mechanism implemented by stashes_ready, that shouldn't have any
+	 * user-visible behavioral consequences, but it would consume shared
+	 * memory to no benefit. It seems better to buffer everything in private
+	 * memory first, and then only apply the changes once the file has been
+	 * successfully parsed in its entirety.
+	 *
+	 * That also has the advantage of possibly being more future-proof: if we
+	 * decide to remove the stashes_ready mechanism in the future, or say
+	 * allow for multiple save files, fully validating the file before
+	 * applying any changes will become much more important.
+	 *
+	 * Of course, this approach does have one major disadvantage, which is
+	 * that we'll temporarily use about twice as much memory as we're
+	 * ultimately going to need, but that seems like it shouldn't be a problem
+	 * in practice. If there's so much stashed advice that parsing the disk
+	 * file runs us out of memory, something has gone terribly wrong. In that
+	 * situation, there probably also isn't enough free memory for the
+	 * workload that the advice is attempting to manipulate to run
+	 * successfully.
+	 */
+	filebuf = palloc_extended(statbuf.st_size + 1, MCXT_ALLOC_HUGE);
+	nread = fread(filebuf, 1, statbuf.st_size, file);
+	if (ferror(file))
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not read file \"%s\": %m", PGSA_DUMP_FILE)));
+	FreeFile(file);
+	filebuf[nread] = '\0';
+
+	/* Initial memory allocations. */
+	saved_stashes = pgsa_saved_stash_table_create(tmpcxt, 64, NULL);
+	entries = palloc(max_entries * sizeof(pgsa_saved_entry));
+
+	/*
+	 * For memory and CPU efficiency, we parse the file in place. The end of
+	 * each line gets replaced with a NUL byte, and then the end of each field
+	 * within a line gets the same treatment. The advice string is unescaped
+	 * in place, and stash names and query IDs can't contain any special
+	 * characters. All of the resulting pointers point right back into the
+	 * buffer; we only need additional memory to grow the 'entries' array and
+	 * the 'saved_stashes' hash table.
+	 */
+	for (p = filebuf, lineno = 1; *p != '\0'; lineno++)
+	{
+		char	   *cursor = p;
+		char	   *eol;
+		char	   *line_type;
+
+		/* Find end of line and NUL-terminate. */
+		eol = strchr(p, '\n');
+		if (eol != NULL)
+		{
+			*eol = '\0';
+			p = eol + 1;
+			if (eol > cursor && eol[-1] == '\r')
+				eol[-1] = '\0';
+		}
+		else
+			p += strlen(p);
+
+		/* Skip empty lines. */
+		if (*cursor == '\0')
+			continue;
+
+		/* First field is the type of line, either "stash" or "entry". */
+		line_type = pgsa_next_tsv_field(&cursor);
+		if (strcmp(line_type, "stash") == 0)
+		{
+			char	   *name;
+			bool		found;
+
+			/* Second field should be the stash name. */
+			name = pgsa_next_tsv_field(&cursor);
+			if (name == NULL || *name == '\0')
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected stash name",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* No further fields are expected. */
+			if (*cursor != '\0')
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected end of line",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* Duplicate check. */
+			(void) pgsa_saved_stash_table_insert(saved_stashes, name, &found);
+			if (found)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: duplicate stash name \"%s\"",
+								PGSA_DUMP_FILE, lineno, name)));
+			num_stashes++;
+		}
+		else if (strcmp(line_type, "entry") == 0)
+		{
+			char	   *stash_name;
+			char	   *queryid_str;
+			char	   *advice_str;
+			char	   *endptr;
+			int64		queryId;
+
+			/* Second field should be the stash name. */
+			stash_name = pgsa_next_tsv_field(&cursor);
+			if (stash_name == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected stash name",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* Third field should be the query ID. */
+			queryid_str = pgsa_next_tsv_field(&cursor);
+			if (queryid_str == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected query ID",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* Fourth field should be the advice string. */
+			advice_str = pgsa_next_tsv_field(&cursor);
+			if (advice_str == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected advice string",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* No further fields are expected. */
+			if (*cursor != '\0')
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected end of line",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* Make sure the stash is one we've actually seen. */
+			if (pgsa_saved_stash_table_lookup(saved_stashes,
+											  stash_name) == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: unknown stash \"%s\"",
+								PGSA_DUMP_FILE, lineno, stash_name)));
+
+			/* Parse the query ID. */
+			errno = 0;
+			queryId = strtoll(queryid_str, &endptr, 10);
+			if (*endptr != '\0' || errno != 0 || queryid_str == endptr)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: invalid query ID \"%s\"",
+								PGSA_DUMP_FILE, lineno, queryid_str)));
+
+			/* Unescape the advice string. */
+			pgsa_unescape_tsv_field(advice_str, PGSA_DUMP_FILE, lineno);
+
+			/* Append to the entry array. */
+			if (num_entries >= max_entries)
+			{
+				max_entries *= 2;
+				entries = repalloc(entries,
+								   max_entries * sizeof(pgsa_saved_entry));
+			}
+			entries[num_entries].stash_name = stash_name;
+			entries[num_entries].queryId = queryId;
+			entries[num_entries].advice_string = advice_str;
+			num_entries++;
+		}
+		else
+		{
+			ereport(ERROR,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("syntax error in file \"%s\" line %u: unrecognized line type",
+							PGSA_DUMP_FILE, lineno)));
+		}
+	}
+
+	/*
+	 * Parsing succeeded. Apply everything to shared memory.
+	 *
+	 * At this point, we know that the file we just read is fully valid, but
+	 * it's still possible for this to fail if, for example, DSA memory cannot
+	 * be allocated. If that happens, the worker will die, the postmaster will
+	 * eventually restart it, and we'll try again after clearing any data that
+	 * we did manage to put into shared memory. (Note that we call
+	 * pgsa_reset_all_stashes() at the top of this function.)
+	 */
+	pgsa_restore_stashes(saved_stashes);
+	pgsa_restore_entries(entries, num_entries);
+
+	/* Hooray, it worked! Notify the user. */
+	ereport(LOG,
+			(errmsg("loaded %d advice stashes and %d entries from \"%s\"",
+					num_stashes, num_entries, PGSA_DUMP_FILE)));
+
+	/* Clean up. */
+	MemoryContextSwitchTo(oldcxt);
+	MemoryContextDelete(tmpcxt);
+}
+
+/*
+ * Write all advice stash data to disk.
+ *
+ * The file format is a simple TSV with a line-type prefix:
+ *   stash\tstash_name
+ *   entry\tstash_name\tquery_id\tadvice_string
+ */
+static void
+pgsa_write_to_disk(void)
+{
+	pgsa_writer_context wctx = {0};
+	MemoryContext tmpcxt;
+	MemoryContext oldcxt;
+
+	Assert(pgsa_entry_dshash != NULL);
+
+	/* Use a temporary context so all allocations are freed at the end. */
+	tmpcxt = AllocSetContextCreate(CurrentMemoryContext,
+								   "pg_stash_advice dump",
+								   ALLOCSET_DEFAULT_SIZES);
+	oldcxt = MemoryContextSwitchTo(tmpcxt);
+
+	/* Set up the writer context. */
+	snprintf(wctx.pathname, MAXPGPATH, "%s.tmp", PGSA_DUMP_FILE);
+	wctx.file = AllocateFile(wctx.pathname, "w");
+	if (!wctx.file)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not open file \"%s\": %m", wctx.pathname)));
+	wctx.nhash = pgsa_stash_name_table_create(tmpcxt, 64, NULL);
+	initStringInfo(&wctx.buf);
+
+	/* Write stash lines, then entry lines. */
+	pgsa_write_stashes(&wctx);
+	pgsa_write_entries(&wctx);
+
+	/*
+	 * If nothing was written, remove both the temp file and any existing dump
+	 * file rather than installing a zero-length file.
+	 */
+	if (wctx.nhash->members == 0)
+	{
+		ereport(DEBUG1,
+				errmsg("there are no advice stashes to save"));
+		FreeFile(wctx.file);
+		unlink(wctx.pathname);
+		if (unlink(PGSA_DUMP_FILE) == 0)
+			ereport(DEBUG1,
+					errmsg("removed \"%s\"", PGSA_DUMP_FILE));
+	}
+	else
+	{
+		if (FreeFile(wctx.file) != 0)
+		{
+			int			save_errno = errno;
+
+			unlink(wctx.pathname);
+			errno = save_errno;
+			ereport(ERROR,
+					(errcode_for_file_access(),
+					 errmsg("could not close file \"%s\": %m",
+							wctx.pathname)));
+		}
+		(void) durable_rename(wctx.pathname, PGSA_DUMP_FILE, ERROR);
+
+		ereport(LOG,
+				errmsg("saved %d advice stashes and %d entries to \"%s\"",
+					   (int) wctx.nhash->members, wctx.entries_written,
+					   PGSA_DUMP_FILE));
+	}
+
+	MemoryContextSwitchTo(oldcxt);
+	MemoryContextDelete(tmpcxt);
+}
+
+/*
+ * Append the TSV-escaped form of str to buf.
+ *
+ * Backslash, tab, newline, and carriage return are escaped with backslash
+ * sequences.  All other characters are passed through unchanged.
+ */
+static void
+pgsa_append_tsv_escaped_string(StringInfo buf, const char *str)
+{
+	for (const char *p = str; *p != '\0'; p++)
+	{
+		switch (*p)
+		{
+			case '\\':
+				appendStringInfoString(buf, "\\\\");
+				break;
+			case '\t':
+				appendStringInfoString(buf, "\\t");
+				break;
+			case '\n':
+				appendStringInfoString(buf, "\\n");
+				break;
+			case '\r':
+				appendStringInfoString(buf, "\\r");
+				break;
+			default:
+				appendStringInfoChar(buf, *p);
+				break;
+		}
+	}
+}
+
+/*
+ * Extract the next tab-delimited field from *cursor.
+ *
+ * The tab delimiter is replaced with '\0' and *cursor is advanced past it.
+ * If *cursor already points to '\0' (no more fields), returns NULL.
+ */
+static char *
+pgsa_next_tsv_field(char **cursor)
+{
+	char	   *start = *cursor;
+	char	   *p = start;
+
+	if (*p == '\0')
+		return NULL;
+
+	while (*p != '\0' && *p != '\t')
+		p++;
+
+	if (*p == '\t')
+		*p++ = '\0';
+
+	*cursor = p;
+	return start;
+}
+
+/*
+ * Insert entries into shared memory from the parsed entry array.
+ */
+static void
+pgsa_restore_entries(pgsa_saved_entry *entries, int num_entries)
+{
+	LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+	for (int i = 0; i < num_entries; i++)
+	{
+		ereport(DEBUG2,
+				errmsg("restoring advice stash entry for \"%s\", query ID %" PRId64,
+					   entries[i].stash_name, entries[i].queryId));
+		pgsa_set_advice_string(entries[i].stash_name,
+							   entries[i].queryId,
+							   entries[i].advice_string);
+	}
+	LWLockRelease(&pgsa_state->lock);
+}
+
+/*
+ * Create stashes in shared memory from the parsed stash hash table.
+ */
+static void
+pgsa_restore_stashes(pgsa_saved_stash_table_hash *saved_stashes)
+{
+	pgsa_saved_stash_table_iterator iter;
+	pgsa_saved_stash *s;
+
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_saved_stash_table_start_iterate(saved_stashes, &iter);
+	while ((s = pgsa_saved_stash_table_iterate(saved_stashes,
+											   &iter)) != NULL)
+	{
+		ereport(DEBUG2,
+				errmsg("restoring advice stash \"%s\"", s->name));
+		pgsa_create_stash(s->name);
+	}
+	LWLockRelease(&pgsa_state->lock);
+}
+
+/*
+ * Unescape a TSV field in place.
+ *
+ * Recognized escape sequences are \\, \t, \n, and \r.  A trailing backslash
+ * or an unrecognized escape sequence is a syntax error.
+ */
+static void
+pgsa_unescape_tsv_field(char *str, const char *filename, unsigned lineno)
+{
+	char	   *src = str;
+	char	   *dst = str;
+
+	while (*src != '\0')
+	{
+		/* Just pass through anything that's not a backslash-escape. */
+		if (likely(*src != '\\'))
+		{
+			*dst++ = *src++;
+			continue;
+		}
+
+		/* Check what sort of escape we've got. */
+		switch (src[1])
+		{
+			case '\\':
+				*dst++ = '\\';
+				break;
+			case 't':
+				*dst++ = '\t';
+				break;
+			case 'n':
+				*dst++ = '\n';
+				break;
+			case 'r':
+				*dst++ = '\r';
+				break;
+			case '\0':
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: trailing backslash",
+								filename, lineno)));
+				break;
+			default:
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: unrecognized escape \"\\%c\"",
+								filename, lineno, src[1])));
+				break;
+		}
+
+		/* We consumed the backslash and the following character. */
+		src += 2;
+	}
+	*dst = '\0';
+}
+
+/*
+ * Write an entry line for each advice entry.
+ */
+static void
+pgsa_write_entries(pgsa_writer_context *wctx)
+{
+	dshash_seq_status iter;
+	pgsa_entry *entry;
+
+	dshash_seq_init(&iter, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iter)) != NULL)
+	{
+		pgsa_stash_name *n;
+		char	   *advice_string;
+
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		n = pgsa_stash_name_table_lookup(wctx->nhash,
+										 entry->key.pgsa_stash_id);
+		if (n == NULL)
+			continue;			/* orphan entry, skip */
+
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+
+		resetStringInfo(&wctx->buf);
+		appendStringInfo(&wctx->buf, "entry\t%s\t%" PRId64 "\t",
+						 n->name, entry->key.queryId);
+		pgsa_append_tsv_escaped_string(&wctx->buf, advice_string);
+		appendStringInfoChar(&wctx->buf, '\n');
+		fwrite(wctx->buf.data, 1, wctx->buf.len, wctx->file);
+		if (ferror(wctx->file))
+			pgsa_write_error(wctx);
+		wctx->entries_written++;
+	}
+	dshash_seq_term(&iter);
+}
+
+/*
+ * Clean up and report a write error.  Does not return.
+ */
+static void
+pgsa_write_error(pgsa_writer_context *wctx)
+{
+	int			save_errno = errno;
+
+	FreeFile(wctx->file);
+	unlink(wctx->pathname);
+	errno = save_errno;
+	ereport(ERROR,
+			(errcode_for_file_access(),
+			 errmsg("could not write to file \"%s\": %m", wctx->pathname)));
+}
+
+/*
+ * Write a stash line for each advice stash, and populate the ID-to-name
+ * hash table for use by pgsa_write_entries.
+ */
+static void
+pgsa_write_stashes(pgsa_writer_context *wctx)
+{
+	dshash_seq_status iter;
+	pgsa_stash *stash;
+
+	dshash_seq_init(&iter, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iter)) != NULL)
+	{
+		pgsa_stash_name *n;
+		bool		found;
+
+		n = pgsa_stash_name_table_insert(wctx->nhash, stash->pgsa_stash_id,
+										 &found);
+		Assert(!found);
+		n->name = pstrdup(stash->name);
+
+		resetStringInfo(&wctx->buf);
+		appendStringInfo(&wctx->buf, "stash\t%s\n", n->name);
+		fwrite(wctx->buf.data, 1, wctx->buf.len, wctx->file);
+		if (ferror(wctx->file))
+			pgsa_write_error(wctx);
+	}
+	dshash_seq_term(&iter);
+}
diff --git a/contrib/pg_stash_advice/t/001_persist.pl b/contrib/pg_stash_advice/t/001_persist.pl
new file mode 100644
index 00000000000..d1466166602
--- /dev/null
+++ b/contrib/pg_stash_advice/t/001_persist.pl
@@ -0,0 +1,84 @@
+
+# Copyright (c) 2016-2026, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+
+$node->init;
+$node->append_conf(
+	'postgresql.conf',
+	qq{shared_preload_libraries = 'pg_plan_advice, pg_stash_advice'
+pg_stash_advice.persist = true
+pg_stash_advice.persist_interval = 0});
+$node->start;
+
+$node->safe_psql("postgres",
+		"CREATE EXTENSION pg_stash_advice;\n");
+
+# Create two stashes: one with 2 entries, one with 1 entry.
+$node->safe_psql("postgres", qq{
+	SELECT pg_create_advice_stash('stash_a');
+	SELECT pg_set_stashed_advice('stash_a', 1001, 'IndexScan(t)');
+	SELECT pg_set_stashed_advice('stash_a', 1002, E'line1\\nline2\\ttab\\\\backslash');
+	SELECT pg_create_advice_stash('stash_b');
+	SELECT pg_set_stashed_advice('stash_b', 2001, 'SeqScan(t)');
+});
+
+# Verify before restart.
+my $result = $node->safe_psql("postgres",
+	"SELECT stash_name, num_entries FROM pg_get_advice_stashes() ORDER BY stash_name");
+is($result, "stash_a|2\nstash_b|1", 'stashes present before restart');
+
+# Restart and verify the data survived.
+$node->restart;
+$node->wait_for_log("loaded 2 advice stashes and 3 entries");
+
+$result = $node->safe_psql("postgres",
+	"SELECT stash_name, num_entries FROM pg_get_advice_stashes() ORDER BY stash_name");
+is($result, "stash_a|2\nstash_b|1", 'stashes survived restart');
+
+# Verify entry contents, including the one with special characters.
+$result = $node->safe_psql("postgres",
+	"SELECT stash_name, query_id, advice_string FROM pg_get_advice_stash_contents(NULL) ORDER BY stash_name, query_id");
+is($result,
+	"stash_a|1001|IndexScan(t)\nstash_a|1002|line1\nline2\ttab\\backslash\nstash_b|2001|SeqScan(t)",
+	'entry contents survived restart with special characters intact');
+
+# Add a third stash with 0 entries.
+$node->safe_psql("postgres", qq{
+	SELECT pg_create_advice_stash('stash_c');
+});
+
+# Restart again and verify all three stashes are present.
+$node->restart;
+$node->wait_for_log("loaded 3 advice stashes and 3 entries");
+
+$result = $node->safe_psql("postgres",
+	"SELECT stash_name, num_entries FROM pg_get_advice_stashes() ORDER BY stash_name");
+is($result, "stash_a|2\nstash_b|1\nstash_c|0", 'all three stashes survived second restart');
+
+# Drop all stashes and verify the dump file is removed after restart.
+$node->safe_psql("postgres", qq{
+	SELECT pg_drop_advice_stash('stash_a');
+	SELECT pg_drop_advice_stash('stash_b');
+	SELECT pg_drop_advice_stash('stash_c');
+});
+
+$node->restart;
+
+$result = $node->safe_psql("postgres",
+	"SELECT count(*) FROM pg_get_advice_stashes()");
+is($result, "0", 'no stashes after dropping all and restarting');
+
+ok(!-f $node->data_dir . '/pg_stash_advice.tsv',
+	'dump file removed after all stashes dropped');
+
+$node->stop;
+
+done_testing();
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
index ec60552a447..810787fe814 100644
--- a/doc/src/sgml/pgstashadvice.sgml
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -15,10 +15,12 @@
   <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
   strings. Whenever a session is asked to plan a query whose query ID appears
   in the relevant advice stash, the plan advice string is automatically applied
-  to guide planning. Note that advice stashes exist purely in memory. This
-  means both that it is important to be mindful of memory consumption when
-  deciding how much plan advice to stash, and also that advice stashes must
-  be recreated and repopulated whenever the server is restarted.
+  to guide planning. Note that advice stashes are stored in dynamically
+  allocated shared memory. This means both that it is important to be mindful
+  of memory consumption when deciding how much plan advice to stash.
+  Optionally, advice stashes and their contents can automatically be persisted
+  to disk and reloaded from disk; see
+  <literal>pg_stash_advice.persist</literal>, below.
  </para>
 
  <para>
@@ -175,6 +177,28 @@
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term>
+     <function>pg_start_stash_advice_worker() returns void</function>
+     <indexterm>
+      <primary>pg_start_stash_advice_worker</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Starts the background worker, so that advice stash contents can be
+      automatically persisted to disk.  If this module is included in
+      <xref linkend="guc-shared-preload-libraries"/> at startup time with
+      <literal>pg_stash_advice.persist = true</literal>, the worker will be
+      started automatically. When started manually, the worker will not load
+      anything from disk, but it will still persist data to disk. You can then
+      configure the server to start the worker automatically after the next
+      restart, preserving any stashed advice you add now.
+     </para>
+    </listitem>
+   </varlistentry>
+
   </variablelist>
 
  </sect2>
@@ -184,6 +208,44 @@
 
   <variablelist>
 
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.persist</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.persist</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Controls whether the advice stashes and stash entries should be
+      persisted to disk. This is on by default. If any stashes are persisted,
+      a file named <literal>pg_stash_advice.tsv</literal> will be created in
+      the data directory. Stashes are loaded and saved using a background
+      worker process.  This parameter can only be set at server start.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.persist_interval</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.persist_interval</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the interval, in seconds, between checks for changes that
+      need to be written to <literal>pg_stash_advice.tsv</literal>. If set to
+      zero, changes are only written when the server shuts down. The default
+      value is <literal>30</literal>. This parameter can only be set in the
+      <filename>postgresql.conf</filename> file or on the server command line.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term>
      <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 840fa73698a..683f9103396 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4056,10 +4056,14 @@ pgpa_trove_slice
 pgpa_unrolled_join
 pgsa_entry
 pgsa_entry_key
+pgsa_saved_entry
+pgsa_saved_stash
+pgsa_saved_stash_table_hash
 pgsa_shared_state
 pgsa_stash
 pgsa_stash_count
 pgsa_stash_name
+pgsa_writer_context
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



  [application/octet-stream] v24-0001-Add-pg_stash_advice-contrib-module.patch (62.9K, 3-v24-0001-Add-pg_stash_advice-contrib-module.patch)
  download | inline diff:
From d8c242865ddb8e49a209197c9e32575461fbc6bf Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 27 Feb 2026 16:58:14 -0500
Subject: [PATCH v24 1/2] Add pg_stash_advice contrib module.

This module allows plan advice strings to be provided automatically
from an in-memory advice stash. Advice stashes are stored in dynamic
shared memory and must be recreated and repopulated after a server
restart. If pg_stash_advice.stash_name is set to the name of an advice
stash, and if query identifiers are enabled, the query identifier
for each query will be looked up in the advice stash and the
associated advice string, if any, will be used each time that query
is planned.

Reviewed-by: Lukas Fittl <[email protected]>
Reviewed-by: Alexandra Wang <[email protected]>
Reviewed-by: David G. Johnston <[email protected]>
Reviewed-by: Jakub Wartak <[email protected]>
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_stash_advice/Makefile              |  27 +
 .../expected/pg_stash_advice.out              | 334 ++++++++++
 contrib/pg_stash_advice/meson.build           |  36 ++
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |  43 ++
 contrib/pg_stash_advice/pg_stash_advice.c     | 605 ++++++++++++++++++
 .../pg_stash_advice/pg_stash_advice.control   |   5 +
 contrib/pg_stash_advice/pg_stash_advice.h     |  99 +++
 .../pg_stash_advice/sql/pg_stash_advice.sql   | 150 +++++
 contrib/pg_stash_advice/stashfuncs.c          | 307 +++++++++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgstashadvice.sgml               | 216 +++++++
 src/tools/pgindent/typedefs.list              |   6 +
 15 files changed, 1832 insertions(+)
 create mode 100644 contrib/pg_stash_advice/Makefile
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice.out
 create mode 100644 contrib/pg_stash_advice/meson.build
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice--1.0.sql
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.c
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.control
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.h
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice.sql
 create mode 100644 contrib/pg_stash_advice/stashfuncs.c
 create mode 100644 doc/src/sgml/pgstashadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index dd04c20acd2..7d91fe77db3 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -36,6 +36,7 @@ SUBDIRS = \
 		pg_overexplain \
 		pg_plan_advice \
 		pg_prewarm	\
+		pg_stash_advice	\
 		pg_stat_statements \
 		pg_surgery	\
 		pg_trgm		\
diff --git a/contrib/meson.build b/contrib/meson.build
index 5a752eac347..ebb7f83d8c5 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -51,6 +51,7 @@ subdir('pg_overexplain')
 subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
+subdir('pg_stash_advice')
 subdir('pg_stat_statements')
 subdir('pgstattuple')
 subdir('pg_surgery')
diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
new file mode 100644
index 00000000000..f5150e14e41
--- /dev/null
+++ b/contrib/pg_stash_advice/Makefile
@@ -0,0 +1,27 @@
+# contrib/pg_stash_advice/Makefile
+
+MODULE_big = pg_stash_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_stash_advice.o \
+	stashfuncs.o
+
+EXTENSION = pg_stash_advice
+DATA = pg_stash_advice--1.0.sql
+PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
+
+REGRESS = pg_stash_advice
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+ifdef USE_PGXS
+PG_CPPFLAGS = -I$(includedir_server)/extension
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice
+subdir = contrib/pg_stash_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
new file mode 100644
index 00000000000..bafe1eba523
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -0,0 +1,334 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
+(13 rows)
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+(13 rows)
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           2
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+ stash_name | advice_string 
+------------+---------------
+(0 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | SEQ_SCAN(d1)
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+ERROR:  advice stash "no_such_stash" does not exist
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           1
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+  stash_name   | advice_string 
+---------------+---------------
+ regress_stash | SEQ_SCAN(d1)
+(1 row)
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+ERROR:  advice stash "regress_stash" already exists
+SELECT pg_drop_advice_stash('no_such_stash');
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+ERROR:  advice stash "no_such_stash" does not exist
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+ERROR:  cannot set advice string for query ID 0
+-- Stash names must be non-empty, ASCII, and not too long, and must look
+-- like identifiers.
+SELECT pg_create_advice_stash('');
+ERROR:  advice stash name may not be zero length
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+ERROR:  advice stash names may not be longer than 63 bytes
+SELECT pg_create_advice_stash(E'caf\u00e9');
+ERROR:  advice stash name must not contain non-ASCII characters
+SELECT pg_create_advice_stash('   ');
+ERROR:  advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores
+SET pg_stash_advice.stash_name = 'café';
+ERROR:  invalid value for parameter "pg_stash_advice.stash_name": "café"
+DETAIL:  advice stash name must not contain non-ASCII characters
+SET pg_stash_advice.stash_name = '99bottles';
+ERROR:  invalid value for parameter "pg_stash_advice.stash_name": "99bottles"
+DETAIL:  advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
+SELECT pg_drop_advice_stash('regress_empty_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
new file mode 100644
index 00000000000..6655f9ab4f2
--- /dev/null
+++ b/contrib/pg_stash_advice/meson.build
@@ -0,0 +1,36 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_stash_advice_sources = files(
+  'pg_stash_advice.c',
+  'stashfuncs.c'
+)
+
+if host_system == 'windows'
+  pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_stash_advice',
+    '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',])
+endif
+
+pg_stash_advice = shared_module('pg_stash_advice',
+  pg_stash_advice_sources,
+  include_directories: [pg_plan_advice_inc, include_directories('.')],
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_stash_advice
+
+install_data(
+  'pg_stash_advice--1.0.sql',
+  'pg_stash_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_stash_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pg_stash_advice',
+    ],
+  },
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
new file mode 100644
index 00000000000..88dedd8ef1b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit
+
+CREATE FUNCTION pg_create_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_create_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_drop_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_drop_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint,
+									  advice_string text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_set_stashed_advice'
+LANGUAGE C;
+
+CREATE FUNCTION pg_get_advice_stashes(
+	OUT stash_name text,
+	OUT num_entries bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stashes'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_advice_stash_contents(
+	INOUT stash_name text,
+	OUT query_id bigint,
+	OUT advice_string text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
+LANGUAGE C;
+
+REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
new file mode 100644
index 00000000000..15e7adf849b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -0,0 +1,605 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.c
+ *	  core infrastructure for pg_stash_advice contrib module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "common/string.h"
+#include "nodes/queryjumble.h"
+#include "pg_plan_advice.h"
+#include "pg_stash_advice.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+/* Shared memory hash table parameters */
+static dshash_parameters pgsa_stash_dshash_parameters = {
+	NAMEDATALEN,
+	sizeof(pgsa_stash),
+	dshash_strcmp,
+	dshash_strhash,
+	dshash_strcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+static dshash_parameters pgsa_entry_dshash_parameters = {
+	sizeof(pgsa_entry_key),
+	sizeof(pgsa_entry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+/* GUC variable */
+static char *pg_stash_advice_stash_name = "";
+
+/* Shared memory pointers */
+pgsa_shared_state *pgsa_state;
+dsa_area *pgsa_dsa_area;
+dshash_table *pgsa_stash_dshash;
+dshash_table *pgsa_entry_dshash;
+
+/* Other global variables */
+static MemoryContext pg_stash_advice_mcxt;
+
+/* Function prototypes */
+static char *pgsa_advisor(PlannerGlobal *glob,
+						  Query *parse,
+						  const char *query_string,
+						  int cursorOptions,
+						  ExplainState *es);
+static bool pgsa_check_stash_name_guc(char **newval, void **extra,
+									  GucSource source);
+static void pgsa_init_shared_state(void *ptr, void *arg);
+static bool pgsa_is_identifier(char *str);
+
+/* Stash name -> stash ID hash table */
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE extern
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/* If compute_query_id = 'auto', we would like query IDs. */
+	EnableQueryId();
+
+	/* Define our GUCs. */
+	DefineCustomStringVariable("pg_stash_advice.stash_name",
+							   "Name of the advice stash to be used in this session.",
+							   NULL,
+							   &pg_stash_advice_stash_name,
+							   "",
+							   PGC_USERSET,
+							   0,
+							   pgsa_check_stash_name_guc,
+							   NULL,
+							   NULL);
+
+	MarkGUCPrefixReserved("pg_stash_advice");
+
+	/* Tell pg_plan_advice that we want to provide advice strings. */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+	(*add_advisor_fn) (pgsa_advisor);
+}
+
+/*
+ * Get the advice string that has been configured for this query, if any,
+ * and return it. Otherwise, return NULL.
+ */
+static char *
+pgsa_advisor(PlannerGlobal *glob, Query *parse,
+			 const char *query_string, int cursorOptions,
+			 ExplainState *es)
+{
+	pgsa_entry_key key;
+	pgsa_entry *entry;
+	char	   *advice_string;
+	uint64		stash_id;
+
+	/*
+	 * Exit quickly if the stash name is empty or there's no query ID.
+	 */
+	if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0)
+		return NULL;
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/*
+	 * Translate pg_stash_advice.stash_name to an integer ID.
+	 *
+	 * pgsa_check_stash_name_guc() has already validated the advice stash
+	 * name, so we don't need to call pgsa_check_stash_name() here.
+	 */
+	stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name);
+	if (stash_id == 0)
+		return NULL;
+
+	/*
+	 * Look up the advice string for the given stash ID + query ID.
+	 *
+	 * If we find an advice string, we copy it into the current memory
+	 * context, presumably short-lived, so that we can release the lock on the
+	 * dshash entry. pg_plan_advice only needs the value to remain allocated
+	 * long enough for it to be parsed, so this should be good enough.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = parse->queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, false);
+	if (entry == NULL)
+		return NULL;
+	if (entry->advice_string == InvalidDsaPointer)
+		advice_string = NULL;
+	else
+		advice_string = pstrdup(dsa_get_address(pgsa_dsa_area,
+												entry->advice_string));
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we found an advice string, emit a debug message. */
+	if (advice_string != NULL)
+		elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s",
+			 pg_stash_advice_stash_name, key.queryId, advice_string);
+
+	return advice_string;
+}
+
+/*
+ * Attach to various structures in dynamic shared memory.
+ *
+ * This function is designed to be resilient against errors. That is, if it
+ * fails partway through, it should be possible to call it again, repeat no
+ * work already completed, and potentially succeed or at least get further if
+ * whatever caused the previous failure has been corrected.
+ */
+void
+pgsa_attach(void)
+{
+	bool		found;
+	MemoryContext oldcontext;
+
+	/*
+	 * Create a memory context to make sure that any control structures
+	 * allocated in local memory are sufficiently persistent.
+	 */
+	if (pg_stash_advice_mcxt == NULL)
+		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
+													 "pg_stash_advice",
+													 ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
+
+	/* Attach to the fixed-size state object if not already done. */
+	if (pgsa_state == NULL)
+		pgsa_state = GetNamedDSMSegment("pg_stash_advice",
+										sizeof(pgsa_shared_state),
+										pgsa_init_shared_state,
+										&found, NULL);
+
+	/* Attach to the DSA area if not already done. */
+	if (pgsa_dsa_area == NULL)
+	{
+		dsa_handle	area_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		area_handle = pgsa_state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
+			dsa_pin(pgsa_dsa_area);
+			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_dsa_area = dsa_attach(area_handle);
+		}
+		dsa_pin_mapping(pgsa_dsa_area);
+	}
+
+	/* Attach to the stash_name->stash_id hash table if not already done. */
+	if (pgsa_stash_dshash == NULL)
+	{
+		dshash_table_handle stash_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
+		stash_handle = pgsa_state->stash_hash;
+		if (stash_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  NULL);
+			pgsa_state->stash_hash =
+				dshash_get_hash_table_handle(pgsa_stash_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  stash_handle, NULL);
+		}
+	}
+
+	/* Attach to the entry hash table if not already done. */
+	if (pgsa_entry_dshash == NULL)
+	{
+		dshash_table_handle entry_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
+		entry_handle = pgsa_state->entry_hash;
+		if (entry_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  NULL);
+			pgsa_state->entry_hash =
+				dshash_get_hash_table_handle(pgsa_entry_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  entry_handle, NULL);
+		}
+	}
+
+	/* Restore previous memory context. */
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Check whether an advice stash name is legal, and signal an error if not.
+ *
+ * Keep this in sync with pgsa_check_stash_name_guc, below.
+ */
+void
+pgsa_check_stash_name(char *stash_name)
+{
+	/* Reject empty advice stash name. */
+	if (stash_name[0] == '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name may not be zero length"));
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash names may not be longer than %d bytes",
+					   NAMEDATALEN - 1));
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must not contain non-ASCII characters"));
+
+	/*
+	 * Reject things that do not look like identifiers, since the ability to
+	 * create an advice stash with non-printable characters or weird symbols
+	 * in the name is not likely to be useful to anyone.
+	 */
+	if (!pgsa_is_identifier(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores"));
+}
+
+/*
+ * As above, but for the GUC check_hook. We allow the empty string here,
+ * though, as equivalent to disabling the feature.
+ */
+static bool
+pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source)
+{
+	char	   *stash_name = *newval;
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash names may not be longer than %d bytes",
+							NAMEDATALEN - 1);
+		return false;
+	}
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must not contain non-ASCII characters");
+		return false;
+	}
+
+	/*
+	 * Reject things that do not look like identifiers, since the ability to
+	 * create an advice stash with non-printable characters or weird symbols
+	 * in the name is not likely to be useful to anyone.
+	 */
+	if (!pgsa_is_identifier(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Create an advice stash.
+ */
+void
+pgsa_create_stash(char *stash_name)
+{
+	pgsa_stash *stash;
+	bool		found;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Create a stash with this name, unless one already exists. */
+	stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found);
+	if (found)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" already exists", stash_name));
+	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+}
+
+/*
+ * Remove any stored advice string for the given advice stash and query ID.
+ */
+void
+pgsa_clear_advice_string(char *stash_name, int64 queryId)
+{
+	pgsa_entry *entry;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer old_dp;
+
+	Assert(LWLockHeldByMe(&pgsa_state->lock));
+
+	/* Translate the stash name to an integer ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/*
+	 * Look for an existing entry, and free it. But, be sure to save the
+	 * pointer to the associated advice string, if any.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		old_dp = InvalidDsaPointer;
+	else
+	{
+		old_dp = entry->advice_string;
+		dshash_delete_entry(pgsa_entry_dshash, entry);
+	}
+
+	/* Now we free the advice string as well, if there was one. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
+
+/*
+ * Drop an advice stash.
+ */
+void
+pgsa_drop_stash(char *stash_name)
+{
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	dshash_seq_status iterator;
+	uint64		stash_id;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove the entry for this advice stash. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, true);
+	if (stash == NULL)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+	stash_id = stash->pgsa_stash_id;
+	dshash_delete_entry(pgsa_stash_dshash, stash);
+
+	/*
+	 * Now remove all the entries. Since pgsa_state->lock must be held at
+	 * least in shared mode to insert entries into pgsa_entry_dshash, it
+	 * doesn't matter whether we do this before or after deleting the entry
+	 * from pgsa_stash_dshash.
+	 */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		if (stash_id == entry->key.pgsa_stash_id)
+		{
+			if (entry->advice_string != InvalidDsaPointer)
+				dsa_free(pgsa_dsa_area, entry->advice_string);
+			dshash_delete_current(&iterator);
+		}
+	}
+	dshash_seq_term(&iterator);
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgsa_init_shared_state(void *ptr, void *arg)
+{
+	pgsa_shared_state *state = (pgsa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_stash_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
+	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
+	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
+	state->next_stash_id = UINT64CONST(1);
+	state->area = DSA_HANDLE_INVALID;
+	state->stash_hash = DSHASH_HANDLE_INVALID;
+	state->entry_hash = DSHASH_HANDLE_INVALID;
+}
+
+/*
+ * Check whether a string looks like a valid identifier. It must contain only
+ * ASCII identifier characters, and must not begin with a digit.
+ */
+static bool
+pgsa_is_identifier(char *str)
+{
+	if (*str >= '0' && *str <= '9')
+		return false;
+
+	while (*str != '\0')
+	{
+		char		c = *str++;
+
+		if ((c < '0' || c > '9') && (c < 'a' || c > 'z') &&
+			(c < 'A' || c > 'Z') && c != '_')
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Look up the integer ID that corresponds to the given stash name.
+ *
+ * Returns 0 if no such stash exists.
+ */
+uint64
+pgsa_lookup_stash_id(char *stash_name)
+{
+	pgsa_stash *stash;
+	uint64		stash_id;
+
+	/* Search the shared hash table. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, false);
+	if (stash == NULL)
+		return 0;
+	stash_id = stash->pgsa_stash_id;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	return stash_id;
+}
+
+/*
+ * Store a new or updated advice string for the given advice stash and query ID.
+ */
+void
+pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
+{
+	pgsa_entry *entry;
+	bool		found;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer new_dp;
+	dsa_pointer old_dp;
+
+	/*
+	 * The caller must hold our lock, at least in shared mode.  This is
+	 * important for two reasons.
+	 *
+	 * First, it holds off interrupts, so that we can't bail out of this code
+	 * after allocating DSA memory for the advice string and before storing
+	 * the resulting pointer somewhere that others can find it.
+	 *
+	 * Second, we need to avoid a race against pgsa_drop_stash(). That
+	 * function removes a stash_name->stash_id mapping and all the entries for
+	 * that stash_id. Without the lock, there's a race condition no matter
+	 * which of those things it does first, because as soon as we've looked up
+	 * the stash ID, that whole function can execute before we do the rest of
+	 * our work, which would result in us adding an entry for a stash that no
+	 * longer exists.
+	 */
+	Assert(LWLockHeldByMe(&pgsa_state->lock));
+
+	/* Look up the stash ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/* Allocate space for the advice string. */
+	new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1);
+	strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string);
+
+	/* Attempt to insert an entry into the hash table. */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find_or_insert_extended(pgsa_entry_dshash, &key, &found,
+										   DSHASH_INSERT_NO_OOM);
+
+	/*
+	 * If it didn't work, bail out, being careful to free the shared memory
+	 * we've already allocated before, since error cleanup will not do so.
+	 */
+	if (entry == NULL)
+	{
+		dsa_free(pgsa_dsa_area, new_dp);
+		ereport(ERROR,
+				errcode(ERRCODE_OUT_OF_MEMORY),
+				errmsg("out of memory"),
+				errdetail("could not insert advice string into shared hash table"));
+	}
+
+	/* Update the entry and release the lock. */
+	old_dp = found ? entry->advice_string : InvalidDsaPointer;
+	entry->advice_string = new_dp;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/*
+	 * We're not safe from leaks yet!
+	 *
+	 * There's now a pointer to new_dp in the entry that we just updated, but
+	 * that means that there's no longer anything pointing to old_dp.
+	 */
+	if (DsaPointerIsValid(old_dp))
+		dsa_free(pgsa_dsa_area, old_dp);
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control
new file mode 100644
index 00000000000..4a0fff5c866
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.control
@@ -0,0 +1,5 @@
+# pg_stash_advice extension
+comment = 'store and automatically apply plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_stash_advice'
+relocatable = true
diff --git a/contrib/pg_stash_advice/pg_stash_advice.h b/contrib/pg_stash_advice/pg_stash_advice.h
new file mode 100644
index 00000000000..eeaa61e0f37
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.h
@@ -0,0 +1,99 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.h
+ *	  main header for pg_stash_advice contrib module
+ *
+ * This module allows plan advice strings (as used and generated by
+ * pg_plan_advice) to be "stashed" in dynamic shared memory and, from
+ * there, automatically be applied to queries as they are planned.
+ * You can create any number of advice stashes, each of which is
+ * identified by a human-readable, ASCII identifier, and each of them is
+ * essentially a query ID -> advice_string mapping.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_STASH_ADVICE_H
+#define PG_STASH_ADVICE_H
+
+#include "lib/dshash.h"
+#include "storage/lwlock.h"
+
+/*
+ * The key that we use to find a particular stash entry.
+ */
+typedef struct pgsa_entry_key
+{
+	uint64		pgsa_stash_id;
+	int64		queryId;
+} pgsa_entry_key;
+
+/*
+ * A single stash entry.
+ */
+typedef struct pgsa_entry
+{
+	pgsa_entry_key key;
+	dsa_pointer advice_string;
+} pgsa_entry;
+
+/*
+ * The stash itself is just a mapping from a name to a stash ID.
+ */
+typedef struct pgsa_stash
+{
+	char		name[NAMEDATALEN];
+	uint64		pgsa_stash_id;
+} pgsa_stash;
+
+/*
+ * Top-level shared state object for pg_stash_advice.
+ */
+typedef struct pgsa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	int			stash_tranche;
+	int			entry_tranche;
+	uint64		next_stash_id;
+	dsa_handle	area;
+	dshash_table_handle stash_hash;
+	dshash_table_handle entry_hash;
+} pgsa_shared_state;
+
+/* For stash ID -> stash name hash table */
+typedef struct pgsa_stash_name
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	char	   *name;
+} pgsa_stash_name;
+
+/* Declare stash ID -> stash name hash table */
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_SCOPE extern
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/* Shared memory pointers */
+extern pgsa_shared_state *pgsa_state;
+extern dsa_area *pgsa_dsa_area;
+extern dshash_table *pgsa_stash_dshash;
+extern dshash_table *pgsa_entry_dshash;
+
+/* Function prototypes */
+extern void pgsa_attach(void);
+extern void pgsa_check_stash_name(char *stash_name);
+extern void pgsa_clear_advice_string(char *stash_name, int64 queryId);
+extern void pgsa_create_stash(char *stash_name);
+extern void pgsa_drop_stash(char *stash_name);
+extern uint64 pgsa_lookup_stash_id(char *stash_name);
+extern void pgsa_set_advice_string(char *stash_name, int64 queryId,
+								   char *advice_string);
+
+#endif
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
new file mode 100644
index 00000000000..e3fe8808937
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
@@ -0,0 +1,150 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+SET pg_stash_advice.stash_name = 'regress_stash';
+
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash') ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY query_id;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash') ORDER BY query_id;
+
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY query_id;
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('no_such_stash');
+
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+
+-- Stash names must be non-empty, ASCII, and not too long, and must look
+-- like identifiers.
+SELECT pg_create_advice_stash('');
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+SELECT pg_create_advice_stash(E'caf\u00e9');
+SELECT pg_create_advice_stash('   ');
+SET pg_stash_advice.stash_name = 'café';
+SET pg_stash_advice.stash_name = '99bottles';
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('regress_empty_stash');
diff --git a/contrib/pg_stash_advice/stashfuncs.c b/contrib/pg_stash_advice/stashfuncs.c
new file mode 100644
index 00000000000..d8c669d6ab7
--- /dev/null
+++ b/contrib/pg_stash_advice/stashfuncs.c
@@ -0,0 +1,307 @@
+/*-------------------------------------------------------------------------
+ *
+ * stashfuncs.c
+ *	  SQL interface to pg_stash_advice
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/stashfuncs.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "pg_stash_advice.h"
+#include "utils/builtins.h"
+#include "utils/tuplestore.h"
+
+PG_FUNCTION_INFO_V1(pg_create_advice_stash);
+PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
+PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
+PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
+PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+
+typedef struct pgsa_stash_count
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	int64		num_entries;
+} pgsa_stash_count;
+
+#define SH_PREFIX pgsa_stash_count_table
+#define SH_ELEMENT_TYPE pgsa_stash_count
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/*
+ * SQL-callable function to create an advice stash
+ */
+Datum
+pg_create_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_create_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to drop an advice stash
+ */
+Datum
+pg_drop_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_drop_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to provide a list of advice stashes
+ */
+Datum
+pg_get_advice_stashes(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	pgsa_stash_count_table_hash *chash;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Tally up the number of entries per stash. */
+	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		pgsa_stash_count *c;
+		bool		found;
+
+		c = pgsa_stash_count_table_insert(chash,
+										  entry->key.pgsa_stash_id,
+										  &found);
+		if (!found)
+			c->num_entries = 1;
+		else
+			c->num_entries++;
+	}
+	dshash_seq_term(&iterator);
+
+	/* Emit results. */
+	dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[2];
+		bool		nulls[2];
+		pgsa_stash_count *c;
+
+		values[0] = CStringGetTextDatum(stash->name);
+		nulls[0] = false;
+
+		c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id);
+		values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries);
+		nulls[1] = false;
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to provide advice stash contents
+ */
+Datum
+pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	char	   *stash_name = NULL;
+	pgsa_stash_name_table_hash *nhash = NULL;
+	uint64		stash_id = 0;
+	pgsa_entry *entry;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* User can pass NULL for all stashes, or the name of a specific stash. */
+	if (!PG_ARGISNULL(0))
+	{
+		stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		pgsa_check_stash_name(stash_name);
+		stash_id = pgsa_lookup_stash_id(stash_name);
+
+		/* If the user specified a stash name, it should exist. */
+		if (stash_id == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("advice stash \"%s\" does not exist", stash_name));
+	}
+	else
+	{
+		pgsa_stash *stash;
+
+		/*
+		 * If we're dumping data about all stashes, we need an ID->name lookup
+		 * table.
+		 */
+		nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+		dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+		while ((stash = dshash_seq_next(&iterator)) != NULL)
+		{
+			pgsa_stash_name *n;
+			bool		found;
+
+			n = pgsa_stash_name_table_insert(nhash,
+											 stash->pgsa_stash_id,
+											 &found);
+			Assert(!found);
+			n->name = pstrdup(stash->name);
+		}
+		dshash_seq_term(&iterator);
+	}
+
+	/* Now iterate over all the entries. */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, false);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		char	   *this_stash_name;
+		char	   *advice_string;
+
+		/* Skip incomplete entries where the advice string was never set. */
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		if (stash_id != 0)
+		{
+			/*
+			 * We're only dumping data for one particular stash, so skip
+			 * entries for any other stash and use the stash name specified by
+			 * the user.
+			 */
+			if (stash_id != entry->key.pgsa_stash_id)
+				continue;
+			this_stash_name = stash_name;
+		}
+		else
+		{
+			pgsa_stash_name *n;
+
+			/*
+			 * We're dumping data for all stashes, so look up the correct name
+			 * to use in the hash table. If nothing is found, which is
+			 * possible due to race conditions, make up a string to use.
+			 */
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n != NULL)
+				this_stash_name = n->name;
+			else
+				this_stash_name = psprintf("<stash %" PRIu64 ">",
+										   entry->key.pgsa_stash_id);
+		}
+
+		/* Work out tuple values. */
+		values[0] = CStringGetTextDatum(this_stash_name);
+		nulls[0] = false;
+		values[1] = Int64GetDatum(entry->key.queryId);
+		nulls[1] = false;
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+		values[2] = CStringGetTextDatum(advice_string);
+		nulls[2] = false;
+
+		/* Emit the tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to update an advice stash entry for a particular
+ * query ID
+ *
+ * If the second argument is NULL, we delete any existing advice stash
+ * entry; otherwise, we either create an entry or update it with the new
+ * advice string.
+ */
+Datum
+pg_set_stashed_advice(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name;
+	int64		queryId;
+
+	if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+		PG_RETURN_NULL();
+
+	/* Get and check advice stash name. */
+	stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	pgsa_check_stash_name(stash_name);
+
+	/*
+	 * Get and check query ID.
+	 *
+	 * queryID 0 means no query ID was computed, so reject that.
+	 */
+	queryId = PG_GETARG_INT64(1);
+	if (queryId == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("cannot set advice string for query ID 0"));
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Now call the appropriate function to do the real work. */
+	if (PG_ARGISNULL(2))
+	{
+		LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+		pgsa_clear_advice_string(stash_name, queryId);
+		LWLockRelease(&pgsa_state->lock);
+	}
+	else
+	{
+		char	   *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+		LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+		pgsa_set_advice_string(stash_name, queryId, advice_string);
+		LWLockRelease(&pgsa_state->lock);
+	}
+
+	PG_RETURN_VOID();
+}
+
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index bdd4865f53f..b9b03654aad 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -159,6 +159,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index d90b4338d2a..e8f758fc24b 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -144,6 +144,7 @@
 <!ENTITY oid2name        SYSTEM "oid2name.sgml">
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
+<!ENTITY pgstashadvice   SYSTEM "pgstashadvice.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
new file mode 100644
index 00000000000..ec60552a447
--- /dev/null
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -0,0 +1,216 @@
+<!-- doc/src/sgml/pgstashadvice.sgml -->
+
+<sect1 id="pgstashadvice" xreflabel="pg_stash_advice">
+ <title>pg_stash_advice &mdash; store and automatically apply plan advice</title>
+
+ <indexterm zone="pgstashadvice">
+  <primary>pg_stash_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_stash_advice</filename> extension allows you to stash
+  <link linkend="pgplanadvice">plan advice</link> strings in dynamic
+  shared memory where they can be automatically applied. An
+  <literal>advice stash</literal> is a mapping from
+  <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
+  strings. Whenever a session is asked to plan a query whose query ID appears
+  in the relevant advice stash, the plan advice string is automatically applied
+  to guide planning. Note that advice stashes exist purely in memory. This
+  means both that it is important to be mindful of memory consumption when
+  deciding how much plan advice to stash, and also that advice stashes must
+  be recreated and repopulated whenever the server is restarted.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
+  one database, so that you have access to the SQL functions to manage
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
+  module to be loaded in all sessions where you want this module to
+  automatically apply advice. It will usually be best to do this by adding
+  <literal>pg_stash_advice</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> and restarting the server.
+ </para>
+
+ <para>
+  Once you have met the above criteria, you can create advice stashes
+  using the <literal>pg_create_advice_stash</literal> function described
+  below and set the plan advice for a given query ID in a given stash using
+  the <literal>pg_set_stashed_advice</literal> function. Then, you need
+  only configure <literal>pg_stash_advice.stash_name</literal> to point
+  to the chosen advice stash name. For some use cases, rather than setting
+  this on a system-wide basis, you may find it helpful to use
+  <literal>ALTER DATABASE ... SET</literal> or
+  <literal>ALTER ROLE ... SET</literal> to configure values that will apply
+  only to a database or only to a certain role. Likewise, it may sometimes
+  be better to set the stash name in a particular session using
+  <literal>SET</literal>.
+ </para>
+
+ <para>
+  Because <literal>pg_stash_advice</literal> works on the basis of query
+  identifiers, you will need to determine the query identifier for each query
+  whose plan you wish to control. You will also need to determine the advice
+  string that you wish to store for each query. One way to do this is to use
+  <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
+  show the query ID, and the <literal>PLAN_ADVICE</literal> option will
+  show plan advice.  Query identifiers can also be obtained through tools
+  such as <xref linkend="pgstatstatements" /> or
+  <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
+  will not provide plan advice strings. Note that
+  <xref linkend="guc-compute-query-id" /> must be enabled for query
+  identifiers to be computed; if set to <literal>auto</literal>, loading
+  <literal>pg_stash_advice</literal> will enable it automatically.
+ </para>
+
+ <para>
+  Generally, the fact that the planner is able to change query plans as
+  the underlying distribution of data changes is a feature, not a bug.
+  Moreover, applying plan advice can have a noticeable performance cost even
+  when it does not result in a change to the query plan. Therefore, it is
+  a good idea to use this feature only when and to the extent needed.
+  Plan advice strings can be trimmed down to mention only those aspects
+  of the plan that need to be controlled, and used only for queries where
+  there is believed to be a significant risk of planner error.
+ </para>
+
+ <para>
+  Note that <literal>pg_stash_advice</literal> currently lacks a sophisticated
+  security model. Only the superuser, or a user to whom the superuser has
+  granted <literal>EXECUTE</literal> permission on the relevant functions,
+  may create advice stashes or alter their contents, but any user may set
+  <literal>pg_stash_advice.stash_name</literal> for their session, and this
+  may reveal the contents of any advice stash with that name. Users should
+  assume that information embedded in stashed advice strings may become visible
+  to nonprivileged users.
+ </para>
+
+ <sect2 id="pgstashadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_create_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_create_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Creates a new, empty advice stash with the given name.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_drop_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_drop_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Drops the named advice stash and all of its entries.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_set_stashed_advice(stash_name text, query_id bigint,
+       advice_string text) returns void</function>
+     <indexterm>
+      <primary>pg_set_stashed_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Stores an advice string in the named advice stash, associated with
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
+      <parameter>advice_string</parameter> is <literal>NULL</literal>,
+      any existing entry for that query identifier is removed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stashes() returns setof (stash_name text,
+       num_entries bigint)</function>
+     <indexterm>
+      <primary>pg_get_advice_stashes</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each advice stash, showing the stash name and
+      the number of entries it contains.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stash_contents(stash_name text) returns setof
+       (stash_name text, query_id bigint, advice_string text)</function>
+     <indexterm>
+      <primary>pg_get_advice_stash_contents</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each entry in the named advice stash. If
+      <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
+      entries from all stashes.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.stash_name</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the name of the advice stash to consult during query
+      planning. The default value is the empty string, which disables
+      this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c9da1f91cb9..840fa73698a 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4054,6 +4054,12 @@ pgpa_trove_lookup_type
 pgpa_trove_result
 pgpa_trove_slice
 pgpa_unrolled_join
+pgsa_entry
+pgsa_entry_key
+pgsa_shared_state
+pgsa_stash
+pgsa_stash_count
+pgsa_stash_name
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-02 16:44  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-04-02 16:44 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Wed, Apr 1, 2026 at 2:34 AM Lukas Fittl <[email protected]> wrote:
> Is there a reason you didn't use GetNamedDSA / GetNamedDSHash for the
> other allocations? (which we have as of fe07100e82b09)

I'm under the impression that GetNamedDSA and GetNamedDSHash exist for
the purposes of making it easy for extensions to coordinate with each
other across backends, rather than being something you're supposed to
use to improve observability. I think it's potentially good for there
to be a way to see the size of every DSA that exists in the system,
but this clearly isn't that, because none of the code in src/backend
uses it when creating DSAs. You might argue that DSAs for short-lived
things like parallel query or parallel VACUUM don't need to be tracked
like this (which seems arguable), but they are also used for
long-lived contexts in the logical replication launcher, by
LISTEN/NOTIFY, by the shared-memory statistics collector, and in
GetSessionDsmHandle(), and those places don't use GetNamedDSA()
either.

Architecturally, I don't like the idea of replacing "having a pointer
to an object" with "being able to look up that object by name". I
think it's good design that pg_stash_advice creates one structure that
serves as a sort of root and then hangs everything else off of that. I
admit that leaves me not knowing quite what to do about the problem of
knowing how much memory it's using, though. Adding a function just to
return size information seems a little clunky, but it might be the
right idea: it could for example return a count of stashes, a count of
entries, the total length of the entries, and the allocated size of
the DSA.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-02 23:43  Tom Lane <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Tom Lane @ 2026-04-02 23:43 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

My animal sifaka just showed an all-new type of test_plan_advice
failure [1]:

diff -U3 /Users/buildfarm/bf-data/HEAD/pgsql.build/src/test/regress/expected/limit.out /Users/buildfarm/bf-data/HEAD/pgsql.build/src/test/modules/test_plan_advice/tmp_check/results/limit.out
--- /Users/buildfarm/bf-data/HEAD/pgsql.build/src/test/regress/expected/limit.out	2026-04-02 12:35:13
+++ /Users/buildfarm/bf-data/HEAD/pgsql.build/src/test/modules/test_plan_advice/tmp_check/results/limit.out	2026-04-02 12:49:59
@@ -5,6 +5,8 @@
 SELECT ''::text AS two, unique1, unique2, stringu1
 		FROM onek WHERE unique1 > 50
 		ORDER BY unique1 LIMIT 2;
+WARNING:  supplied plan advice was not enforced
+DETAIL:  advice INDEX_SCAN(onek public.onek_unique1) feedback is "matched, inapplicable, failed"
  two | unique1 | unique2 | stringu1 
 -----+---------+---------+----------
      |      51 |      76 | ZBAAAA
=== EOF ===
[12:50:02.062](11.620s) not ok 1 - regression tests pass

This is unlike the other cases we've been looking at: no sub-selects,
no GEQO, not even any joins.  There is pretty much only one plausible
plan for that query, so how did it fail?

After looking around, I think the likely explanation is that the
concurrently-run alter_table.sql test feels free to mess with the set
of indexes on onek.  It doesn't drop onek_unique1, but it does
momentarily rename it:

ALTER INDEX onek_unique1 RENAME TO attmp_onek_unique1;
ALTER INDEX attmp_onek_unique1 RENAME TO onek_unique1;

I've not looked closely at pg_plan_advice, but if it matches indexes
by name then it seems there's a window here where the advice would
fail to apply.  Also, further down we find

ALTER TABLE onek ADD CONSTRAINT onek_unique1_constraint UNIQUE (unique1);
ALTER INDEX onek_unique1_constraint RENAME TO onek_unique1_constraint_foo;
ALTER TABLE onek DROP CONSTRAINT onek_unique1_constraint_foo;

which means there's a window there where there are two plausible
indexes to choose.  Will test_plan_advice cope if the transient one
is chosen?

We could imagine dodging this problem either by having alter_table.sql
test some purpose-built table instead of a shared one, or by having it
do these hacks inside transactions so that other sessions can't see
the intermediate states.  But I'm quite resistant to that answer,
because I think part of the point here is to ensure that concurrent
DDL doesn't misbehave.  (Which it doesn't: these test fragments have
been there since 2018 and 2012 respectively, and not caused issues
AFAIK.)  Preventing our tests from exercising concurrent DDL in order
to satisfy test_plan_advice is not a good plan IMO.  There's also the
prospect of a long tail of whack-a-mole as we locate other places in
the tests where this sort of thing happens occasionally.

So I'm not sure what to do here, but we have a problem.

			regards, tom lane

[1] https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=sifaka&dt=2026-04-02%2016%3A35%3A13





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-03 02:08  Robert Haas <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-03 02:08 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

Thanks for the report.

On Thu, Apr 2, 2026 at 7:43 PM Tom Lane <[email protected]> wrote:
> After looking around, I think the likely explanation is that the
> concurrently-run alter_table.sql test feels free to mess with the set
> of indexes on onek.  It doesn't drop onek_unique1, but it does
> momentarily rename it:
>
> ALTER INDEX onek_unique1 RENAME TO attmp_onek_unique1;
> ALTER INDEX attmp_onek_unique1 RENAME TO onek_unique1;

Yeah, the fact that it said /* inapplicable */ strongly supports this
theory. There's only two ways that can happen, and an index not
existing with the expected name is one of them (and the only one
that's possible in a query involving only a single table).

> I've not looked closely at pg_plan_advice, but if it matches indexes
> by name then it seems there's a window here where the advice would
> fail to apply.  Also, further down we find
>
> ALTER TABLE onek ADD CONSTRAINT onek_unique1_constraint UNIQUE (unique1);
> ALTER INDEX onek_unique1_constraint RENAME TO onek_unique1_constraint_foo;
> ALTER TABLE onek DROP CONSTRAINT onek_unique1_constraint_foo;
>
> which means there's a window there where there are two plausible
> indexes to choose.  Will test_plan_advice cope if the transient one
> is chosen?

It's going to be unhappy if the second planning cycle is incapable of
choosing the same index that the first planning cycle did. I have to
admit that it didn't occur to me that our regression tests would do
something like this. I figured they had to be operating on mostly
independent objects or things would already be broken, but I failed to
consider the possibility that there could be concurrent DDL of a sort
that wouldn't affect the normal running of the regression tests but
would affect pg_plan_advice. Or at least, if I did ever consider it, I
stopped considering it when test_plan_advice appeared to be passing.

> So I'm not sure what to do here, but we have a problem.

I'm not sure, either, and I agree that we have a problem. I'll give it
some more thought tomorrow.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-03 02:15  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-03 02:15 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Apr 2, 2026 at 12:15 PM Robert Haas <[email protected]> wrote:
> So here's v24, also dropping pg_collect_advice.

That version didn't actually pass CI. Here's v25.

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v25-0001-Add-pg_stash_advice-contrib-module.patch (65.0K, 2-v25-0001-Add-pg_stash_advice-contrib-module.patch)
  download | inline diff:
From 7451adce350626e64755abeecde757b3dfbd6de2 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Fri, 27 Feb 2026 16:58:14 -0500
Subject: [PATCH v25 1/2] Add pg_stash_advice contrib module.

This module allows plan advice strings to be provided automatically
from an in-memory advice stash. Advice stashes are stored in dynamic
shared memory and must be recreated and repopulated after a server
restart. If pg_stash_advice.stash_name is set to the name of an advice
stash, and if query identifiers are enabled, the query identifier
for each query will be looked up in the advice stash and the
associated advice string, if any, will be used each time that query
is planned.

Reviewed-by: Lukas Fittl <[email protected]>
Reviewed-by: Alexandra Wang <[email protected]>
Reviewed-by: David G. Johnston <[email protected]>
Reviewed-by: Jakub Wartak <[email protected]>
---
 contrib/Makefile                              |   1 +
 contrib/meson.build                           |   1 +
 contrib/pg_stash_advice/Makefile              |  27 +
 .../expected/pg_stash_advice.out              | 331 ++++++++++
 .../expected/pg_stash_advice_utf8.out         |  16 +
 .../expected/pg_stash_advice_utf8_1.out       |   8 +
 contrib/pg_stash_advice/meson.build           |  37 ++
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |  43 ++
 contrib/pg_stash_advice/pg_stash_advice.c     | 605 ++++++++++++++++++
 .../pg_stash_advice/pg_stash_advice.control   |   5 +
 contrib/pg_stash_advice/pg_stash_advice.h     |  99 +++
 .../pg_stash_advice/sql/pg_stash_advice.sql   | 150 +++++
 .../sql/pg_stash_advice_utf8.sql              |  16 +
 contrib/pg_stash_advice/stashfuncs.c          | 307 +++++++++
 doc/src/sgml/contrib.sgml                     |   1 +
 doc/src/sgml/filelist.sgml                    |   1 +
 doc/src/sgml/pgstashadvice.sgml               | 216 +++++++
 src/tools/pgindent/typedefs.list              |   6 +
 18 files changed, 1870 insertions(+)
 create mode 100644 contrib/pg_stash_advice/Makefile
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice.out
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice_utf8.out
 create mode 100644 contrib/pg_stash_advice/expected/pg_stash_advice_utf8_1.out
 create mode 100644 contrib/pg_stash_advice/meson.build
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice--1.0.sql
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.c
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.control
 create mode 100644 contrib/pg_stash_advice/pg_stash_advice.h
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice.sql
 create mode 100644 contrib/pg_stash_advice/sql/pg_stash_advice_utf8.sql
 create mode 100644 contrib/pg_stash_advice/stashfuncs.c
 create mode 100644 doc/src/sgml/pgstashadvice.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index dd04c20acd2..7d91fe77db3 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -36,6 +36,7 @@ SUBDIRS = \
 		pg_overexplain \
 		pg_plan_advice \
 		pg_prewarm	\
+		pg_stash_advice	\
 		pg_stat_statements \
 		pg_surgery	\
 		pg_trgm		\
diff --git a/contrib/meson.build b/contrib/meson.build
index 5a752eac347..ebb7f83d8c5 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -51,6 +51,7 @@ subdir('pg_overexplain')
 subdir('pg_plan_advice')
 subdir('pg_prewarm')
 subdir('pgrowlocks')
+subdir('pg_stash_advice')
 subdir('pg_stat_statements')
 subdir('pgstattuple')
 subdir('pg_surgery')
diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
new file mode 100644
index 00000000000..f7670c2d4b6
--- /dev/null
+++ b/contrib/pg_stash_advice/Makefile
@@ -0,0 +1,27 @@
+# contrib/pg_stash_advice/Makefile
+
+MODULE_big = pg_stash_advice
+OBJS = \
+	$(WIN32RES) \
+	pg_stash_advice.o \
+	stashfuncs.o
+
+EXTENSION = pg_stash_advice
+DATA = pg_stash_advice--1.0.sql
+PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
+
+REGRESS = pg_stash_advice pg_stash_advice_utf8
+EXTRA_INSTALL = contrib/pg_plan_advice
+
+ifdef USE_PGXS
+PG_CPPFLAGS = -I$(includedir_server)/extension
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice
+subdir = contrib/pg_stash_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
new file mode 100644
index 00000000000..788da854aa7
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -0,0 +1,331 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
+ Supplied Plan Advice:
+   INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
+(13 rows)
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim2_id = d2.id)
+   ->  Hash Join
+         Hash Cond: (f.dim1_id = d1.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim1 d1
+                     Filter: (val1 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim2 d2
+               Filter: (val2 = 1)
+ Supplied Plan Advice:
+   JOIN_ORDER(f d1 d2) /* matched */
+(13 rows)
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+         Index Cond: (id = f.dim1_id)
+         Filter: (val1 = 1)
+ Supplied Plan Advice:
+   NESTED_LOOP_PLAIN(d1) /* matched */
+(12 rows)
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+ pg_create_advice_stash 
+------------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           2
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY advice_string;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+ regress_stash | SEQ_SCAN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash')
+	 ORDER BY advice_string;
+ stash_name | advice_string 
+------------+---------------
+(0 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY advice_string;
+  stash_name   |     advice_string     
+---------------+-----------------------
+ regress_stash | NESTED_LOOP_PLAIN(d1)
+ regress_stash | SEQ_SCAN(d1)
+(2 rows)
+
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash')
+	 ORDER BY advice_string;
+ERROR:  advice stash "no_such_stash" does not exist
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+ pg_set_stashed_advice 
+-----------------------
+ 
+(1 row)
+
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+                QUERY PLAN                
+------------------------------------------
+ Hash Join
+   Hash Cond: (f.dim1_id = d1.id)
+   ->  Hash Join
+         Hash Cond: (f.dim2_id = d2.id)
+         ->  Seq Scan on aa_fact f
+         ->  Hash
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
+   ->  Hash
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
+
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+     stash_name      | num_entries 
+---------------------+-------------
+ regress_empty_stash |           0
+ regress_stash       |           1
+(2 rows)
+
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY advice_string;
+  stash_name   | advice_string 
+---------------+---------------
+ regress_stash | SEQ_SCAN(d1)
+(1 row)
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+ERROR:  advice stash "regress_stash" already exists
+SELECT pg_drop_advice_stash('no_such_stash');
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+ERROR:  advice stash "no_such_stash" does not exist
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+ERROR:  advice stash "no_such_stash" does not exist
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+ERROR:  cannot set advice string for query ID 0
+-- Stash names must be non-empty, ASCII, and not too long, and must look
+-- like identifiers.
+SELECT pg_create_advice_stash('');
+ERROR:  advice stash name may not be zero length
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+ERROR:  advice stash names may not be longer than 63 bytes
+SELECT pg_create_advice_stash('   ');
+ERROR:  advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores
+SET pg_stash_advice.stash_name = '99bottles';
+ERROR:  invalid value for parameter "pg_stash_advice.stash_name": "99bottles"
+DETAIL:  advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
+SELECT pg_drop_advice_stash('regress_empty_stash');
+ pg_drop_advice_stash 
+----------------------
+ 
+(1 row)
+
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice_utf8.out b/contrib/pg_stash_advice/expected/pg_stash_advice_utf8.out
new file mode 100644
index 00000000000..7c532571ed5
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice_utf8.out
@@ -0,0 +1,16 @@
+/*
+ * This test must be run in a database with UTF-8 encoding,
+ * because other encodings don't support all the characters used.
+ */
+SELECT getdatabaseencoding() <> 'UTF8'
+       AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+SET client_encoding = utf8;
+-- Non-ASCII stash names should be rejected.
+SELECT pg_create_advice_stash('café');
+ERROR:  advice stash name must not contain non-ASCII characters
+SET pg_stash_advice.stash_name = 'café';
+ERROR:  invalid value for parameter "pg_stash_advice.stash_name": "café"
+DETAIL:  advice stash name must not contain non-ASCII characters
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice_utf8_1.out b/contrib/pg_stash_advice/expected/pg_stash_advice_utf8_1.out
new file mode 100644
index 00000000000..37aead89c0c
--- /dev/null
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice_utf8_1.out
@@ -0,0 +1,8 @@
+/*
+ * This test must be run in a database with UTF-8 encoding,
+ * because other encodings don't support all the characters used.
+ */
+SELECT getdatabaseencoding() <> 'UTF8'
+       AS skip_test \gset
+\if :skip_test
+\quit
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
new file mode 100644
index 00000000000..8fbcfcf8693
--- /dev/null
+++ b/contrib/pg_stash_advice/meson.build
@@ -0,0 +1,37 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_stash_advice_sources = files(
+  'pg_stash_advice.c',
+  'stashfuncs.c'
+)
+
+if host_system == 'windows'
+  pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_stash_advice',
+    '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',])
+endif
+
+pg_stash_advice = shared_module('pg_stash_advice',
+  pg_stash_advice_sources,
+  include_directories: [pg_plan_advice_inc, include_directories('.')],
+  kwargs: contrib_mod_args,
+)
+contrib_targets += pg_stash_advice
+
+install_data(
+  'pg_stash_advice--1.0.sql',
+  'pg_stash_advice.control',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'pg_stash_advice',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'pg_stash_advice',
+      'pg_stash_advice_utf8',
+    ],
+  },
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
new file mode 100644
index 00000000000..88dedd8ef1b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -0,0 +1,43 @@
+/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit
+
+CREATE FUNCTION pg_create_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_create_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_drop_advice_stash(stash_name text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_drop_advice_stash'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint,
+									  advice_string text)
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_set_stashed_advice'
+LANGUAGE C;
+
+CREATE FUNCTION pg_get_advice_stashes(
+	OUT stash_name text,
+	OUT num_entries bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stashes'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_advice_stash_contents(
+	INOUT stash_name text,
+	OUT query_id bigint,
+	OUT advice_string text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
+LANGUAGE C;
+
+REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
new file mode 100644
index 00000000000..15e7adf849b
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -0,0 +1,605 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.c
+ *	  core infrastructure for pg_stash_advice contrib module
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "common/string.h"
+#include "nodes/queryjumble.h"
+#include "pg_plan_advice.h"
+#include "pg_stash_advice.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+/* Shared memory hash table parameters */
+static dshash_parameters pgsa_stash_dshash_parameters = {
+	NAMEDATALEN,
+	sizeof(pgsa_stash),
+	dshash_strcmp,
+	dshash_strhash,
+	dshash_strcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+static dshash_parameters pgsa_entry_dshash_parameters = {
+	sizeof(pgsa_entry_key),
+	sizeof(pgsa_entry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_INVALID			/* gets set at runtime */
+};
+
+/* GUC variable */
+static char *pg_stash_advice_stash_name = "";
+
+/* Shared memory pointers */
+pgsa_shared_state *pgsa_state;
+dsa_area *pgsa_dsa_area;
+dshash_table *pgsa_stash_dshash;
+dshash_table *pgsa_entry_dshash;
+
+/* Other global variables */
+static MemoryContext pg_stash_advice_mcxt;
+
+/* Function prototypes */
+static char *pgsa_advisor(PlannerGlobal *glob,
+						  Query *parse,
+						  const char *query_string,
+						  int cursorOptions,
+						  ExplainState *es);
+static bool pgsa_check_stash_name_guc(char **newval, void **extra,
+									  GucSource source);
+static void pgsa_init_shared_state(void *ptr, void *arg);
+static bool pgsa_is_identifier(char *str);
+
+/* Stash name -> stash ID hash table */
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE extern
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
+
+	/* If compute_query_id = 'auto', we would like query IDs. */
+	EnableQueryId();
+
+	/* Define our GUCs. */
+	DefineCustomStringVariable("pg_stash_advice.stash_name",
+							   "Name of the advice stash to be used in this session.",
+							   NULL,
+							   &pg_stash_advice_stash_name,
+							   "",
+							   PGC_USERSET,
+							   0,
+							   pgsa_check_stash_name_guc,
+							   NULL,
+							   NULL);
+
+	MarkGUCPrefixReserved("pg_stash_advice");
+
+	/* Tell pg_plan_advice that we want to provide advice strings. */
+	add_advisor_fn =
+		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
+							   true, NULL);
+	(*add_advisor_fn) (pgsa_advisor);
+}
+
+/*
+ * Get the advice string that has been configured for this query, if any,
+ * and return it. Otherwise, return NULL.
+ */
+static char *
+pgsa_advisor(PlannerGlobal *glob, Query *parse,
+			 const char *query_string, int cursorOptions,
+			 ExplainState *es)
+{
+	pgsa_entry_key key;
+	pgsa_entry *entry;
+	char	   *advice_string;
+	uint64		stash_id;
+
+	/*
+	 * Exit quickly if the stash name is empty or there's no query ID.
+	 */
+	if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0)
+		return NULL;
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/*
+	 * Translate pg_stash_advice.stash_name to an integer ID.
+	 *
+	 * pgsa_check_stash_name_guc() has already validated the advice stash
+	 * name, so we don't need to call pgsa_check_stash_name() here.
+	 */
+	stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name);
+	if (stash_id == 0)
+		return NULL;
+
+	/*
+	 * Look up the advice string for the given stash ID + query ID.
+	 *
+	 * If we find an advice string, we copy it into the current memory
+	 * context, presumably short-lived, so that we can release the lock on the
+	 * dshash entry. pg_plan_advice only needs the value to remain allocated
+	 * long enough for it to be parsed, so this should be good enough.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = parse->queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, false);
+	if (entry == NULL)
+		return NULL;
+	if (entry->advice_string == InvalidDsaPointer)
+		advice_string = NULL;
+	else
+		advice_string = pstrdup(dsa_get_address(pgsa_dsa_area,
+												entry->advice_string));
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/* If we found an advice string, emit a debug message. */
+	if (advice_string != NULL)
+		elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s",
+			 pg_stash_advice_stash_name, key.queryId, advice_string);
+
+	return advice_string;
+}
+
+/*
+ * Attach to various structures in dynamic shared memory.
+ *
+ * This function is designed to be resilient against errors. That is, if it
+ * fails partway through, it should be possible to call it again, repeat no
+ * work already completed, and potentially succeed or at least get further if
+ * whatever caused the previous failure has been corrected.
+ */
+void
+pgsa_attach(void)
+{
+	bool		found;
+	MemoryContext oldcontext;
+
+	/*
+	 * Create a memory context to make sure that any control structures
+	 * allocated in local memory are sufficiently persistent.
+	 */
+	if (pg_stash_advice_mcxt == NULL)
+		pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext,
+													 "pg_stash_advice",
+													 ALLOCSET_DEFAULT_SIZES);
+	oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt);
+
+	/* Attach to the fixed-size state object if not already done. */
+	if (pgsa_state == NULL)
+		pgsa_state = GetNamedDSMSegment("pg_stash_advice",
+										sizeof(pgsa_shared_state),
+										pgsa_init_shared_state,
+										&found, NULL);
+
+	/* Attach to the DSA area if not already done. */
+	if (pgsa_dsa_area == NULL)
+	{
+		dsa_handle	area_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		area_handle = pgsa_state->area;
+		if (area_handle == DSA_HANDLE_INVALID)
+		{
+			pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche);
+			dsa_pin(pgsa_dsa_area);
+			pgsa_state->area = dsa_get_handle(pgsa_dsa_area);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_dsa_area = dsa_attach(area_handle);
+		}
+		dsa_pin_mapping(pgsa_dsa_area);
+	}
+
+	/* Attach to the stash_name->stash_id hash table if not already done. */
+	if (pgsa_stash_dshash == NULL)
+	{
+		dshash_table_handle stash_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche;
+		stash_handle = pgsa_state->stash_hash;
+		if (stash_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_stash_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  NULL);
+			pgsa_state->stash_hash =
+				dshash_get_hash_table_handle(pgsa_stash_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_stash_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_stash_dshash_parameters,
+											  stash_handle, NULL);
+		}
+	}
+
+	/* Attach to the entry hash table if not already done. */
+	if (pgsa_entry_dshash == NULL)
+	{
+		dshash_table_handle entry_handle;
+
+		LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+		pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche;
+		entry_handle = pgsa_state->entry_hash;
+		if (entry_handle == DSHASH_HANDLE_INVALID)
+		{
+			pgsa_entry_dshash = dshash_create(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  NULL);
+			pgsa_state->entry_hash =
+				dshash_get_hash_table_handle(pgsa_entry_dshash);
+			LWLockRelease(&pgsa_state->lock);
+		}
+		else
+		{
+			LWLockRelease(&pgsa_state->lock);
+			pgsa_entry_dshash = dshash_attach(pgsa_dsa_area,
+											  &pgsa_entry_dshash_parameters,
+											  entry_handle, NULL);
+		}
+	}
+
+	/* Restore previous memory context. */
+	MemoryContextSwitchTo(oldcontext);
+}
+
+/*
+ * Check whether an advice stash name is legal, and signal an error if not.
+ *
+ * Keep this in sync with pgsa_check_stash_name_guc, below.
+ */
+void
+pgsa_check_stash_name(char *stash_name)
+{
+	/* Reject empty advice stash name. */
+	if (stash_name[0] == '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name may not be zero length"));
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash names may not be longer than %d bytes",
+					   NAMEDATALEN - 1));
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must not contain non-ASCII characters"));
+
+	/*
+	 * Reject things that do not look like identifiers, since the ability to
+	 * create an advice stash with non-printable characters or weird symbols
+	 * in the name is not likely to be useful to anyone.
+	 */
+	if (!pgsa_is_identifier(stash_name))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores"));
+}
+
+/*
+ * As above, but for the GUC check_hook. We allow the empty string here,
+ * though, as equivalent to disabling the feature.
+ */
+static bool
+pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source)
+{
+	char	   *stash_name = *newval;
+
+	/* Reject overlong advice stash names. */
+	if (strlen(stash_name) + 1 > NAMEDATALEN)
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash names may not be longer than %d bytes",
+							NAMEDATALEN - 1);
+		return false;
+	}
+
+	/*
+	 * Reject non-ASCII advice stash names, since advice stashes are visible
+	 * across all databases and the encodings of those databases might differ.
+	 */
+	if (!pg_is_ascii(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must not contain non-ASCII characters");
+		return false;
+	}
+
+	/*
+	 * Reject things that do not look like identifiers, since the ability to
+	 * create an advice stash with non-printable characters or weird symbols
+	 * in the name is not likely to be useful to anyone.
+	 */
+	if (!pgsa_is_identifier(stash_name))
+	{
+		GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE);
+		GUC_check_errdetail("advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Create an advice stash.
+ */
+void
+pgsa_create_stash(char *stash_name)
+{
+	pgsa_stash *stash;
+	bool		found;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Create a stash with this name, unless one already exists. */
+	stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found);
+	if (found)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" already exists", stash_name));
+	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+}
+
+/*
+ * Remove any stored advice string for the given advice stash and query ID.
+ */
+void
+pgsa_clear_advice_string(char *stash_name, int64 queryId)
+{
+	pgsa_entry *entry;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer old_dp;
+
+	Assert(LWLockHeldByMe(&pgsa_state->lock));
+
+	/* Translate the stash name to an integer ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/*
+	 * Look for an existing entry, and free it. But, be sure to save the
+	 * pointer to the associated advice string, if any.
+	 */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find(pgsa_entry_dshash, &key, true);
+	if (entry == NULL)
+		old_dp = InvalidDsaPointer;
+	else
+	{
+		old_dp = entry->advice_string;
+		dshash_delete_entry(pgsa_entry_dshash, entry);
+	}
+
+	/* Now we free the advice string as well, if there was one. */
+	if (old_dp != InvalidDsaPointer)
+		dsa_free(pgsa_dsa_area, old_dp);
+}
+
+/*
+ * Drop an advice stash.
+ */
+void
+pgsa_drop_stash(char *stash_name)
+{
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	dshash_seq_status iterator;
+	uint64		stash_id;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove the entry for this advice stash. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, true);
+	if (stash == NULL)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+	stash_id = stash->pgsa_stash_id;
+	dshash_delete_entry(pgsa_stash_dshash, stash);
+
+	/*
+	 * Now remove all the entries. Since pgsa_state->lock must be held at
+	 * least in shared mode to insert entries into pgsa_entry_dshash, it
+	 * doesn't matter whether we do this before or after deleting the entry
+	 * from pgsa_stash_dshash.
+	 */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		if (stash_id == entry->key.pgsa_stash_id)
+		{
+			if (entry->advice_string != InvalidDsaPointer)
+				dsa_free(pgsa_dsa_area, entry->advice_string);
+			dshash_delete_current(&iterator);
+		}
+	}
+	dshash_seq_term(&iterator);
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgsa_init_shared_state(void *ptr, void *arg)
+{
+	pgsa_shared_state *state = (pgsa_shared_state *) ptr;
+
+	LWLockInitialize(&state->lock,
+					 LWLockNewTrancheId("pg_stash_advice_lock"));
+	state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa");
+	state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash");
+	state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry");
+	state->next_stash_id = UINT64CONST(1);
+	state->area = DSA_HANDLE_INVALID;
+	state->stash_hash = DSHASH_HANDLE_INVALID;
+	state->entry_hash = DSHASH_HANDLE_INVALID;
+}
+
+/*
+ * Check whether a string looks like a valid identifier. It must contain only
+ * ASCII identifier characters, and must not begin with a digit.
+ */
+static bool
+pgsa_is_identifier(char *str)
+{
+	if (*str >= '0' && *str <= '9')
+		return false;
+
+	while (*str != '\0')
+	{
+		char		c = *str++;
+
+		if ((c < '0' || c > '9') && (c < 'a' || c > 'z') &&
+			(c < 'A' || c > 'Z') && c != '_')
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Look up the integer ID that corresponds to the given stash name.
+ *
+ * Returns 0 if no such stash exists.
+ */
+uint64
+pgsa_lookup_stash_id(char *stash_name)
+{
+	pgsa_stash *stash;
+	uint64		stash_id;
+
+	/* Search the shared hash table. */
+	stash = dshash_find(pgsa_stash_dshash, stash_name, false);
+	if (stash == NULL)
+		return 0;
+	stash_id = stash->pgsa_stash_id;
+	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	return stash_id;
+}
+
+/*
+ * Store a new or updated advice string for the given advice stash and query ID.
+ */
+void
+pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
+{
+	pgsa_entry *entry;
+	bool		found;
+	pgsa_entry_key key;
+	uint64		stash_id;
+	dsa_pointer new_dp;
+	dsa_pointer old_dp;
+
+	/*
+	 * The caller must hold our lock, at least in shared mode.  This is
+	 * important for two reasons.
+	 *
+	 * First, it holds off interrupts, so that we can't bail out of this code
+	 * after allocating DSA memory for the advice string and before storing
+	 * the resulting pointer somewhere that others can find it.
+	 *
+	 * Second, we need to avoid a race against pgsa_drop_stash(). That
+	 * function removes a stash_name->stash_id mapping and all the entries for
+	 * that stash_id. Without the lock, there's a race condition no matter
+	 * which of those things it does first, because as soon as we've looked up
+	 * the stash ID, that whole function can execute before we do the rest of
+	 * our work, which would result in us adding an entry for a stash that no
+	 * longer exists.
+	 */
+	Assert(LWLockHeldByMe(&pgsa_state->lock));
+
+	/* Look up the stash ID. */
+	if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("advice stash \"%s\" does not exist", stash_name));
+
+	/* Allocate space for the advice string. */
+	new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1);
+	strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string);
+
+	/* Attempt to insert an entry into the hash table. */
+	memset(&key, 0, sizeof(pgsa_entry_key));
+	key.pgsa_stash_id = stash_id;
+	key.queryId = queryId;
+	entry = dshash_find_or_insert_extended(pgsa_entry_dshash, &key, &found,
+										   DSHASH_INSERT_NO_OOM);
+
+	/*
+	 * If it didn't work, bail out, being careful to free the shared memory
+	 * we've already allocated before, since error cleanup will not do so.
+	 */
+	if (entry == NULL)
+	{
+		dsa_free(pgsa_dsa_area, new_dp);
+		ereport(ERROR,
+				errcode(ERRCODE_OUT_OF_MEMORY),
+				errmsg("out of memory"),
+				errdetail("could not insert advice string into shared hash table"));
+	}
+
+	/* Update the entry and release the lock. */
+	old_dp = found ? entry->advice_string : InvalidDsaPointer;
+	entry->advice_string = new_dp;
+	dshash_release_lock(pgsa_entry_dshash, entry);
+
+	/*
+	 * We're not safe from leaks yet!
+	 *
+	 * There's now a pointer to new_dp in the entry that we just updated, but
+	 * that means that there's no longer anything pointing to old_dp.
+	 */
+	if (DsaPointerIsValid(old_dp))
+		dsa_free(pgsa_dsa_area, old_dp);
+}
diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control
new file mode 100644
index 00000000000..4a0fff5c866
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.control
@@ -0,0 +1,5 @@
+# pg_stash_advice extension
+comment = 'store and automatically apply plan advice'
+default_version = '1.0'
+module_pathname = '$libdir/pg_stash_advice'
+relocatable = true
diff --git a/contrib/pg_stash_advice/pg_stash_advice.h b/contrib/pg_stash_advice/pg_stash_advice.h
new file mode 100644
index 00000000000..eeaa61e0f37
--- /dev/null
+++ b/contrib/pg_stash_advice/pg_stash_advice.h
@@ -0,0 +1,99 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_stash_advice.h
+ *	  main header for pg_stash_advice contrib module
+ *
+ * This module allows plan advice strings (as used and generated by
+ * pg_plan_advice) to be "stashed" in dynamic shared memory and, from
+ * there, automatically be applied to queries as they are planned.
+ * You can create any number of advice stashes, each of which is
+ * identified by a human-readable, ASCII identifier, and each of them is
+ * essentially a query ID -> advice_string mapping.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/pg_stash_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_STASH_ADVICE_H
+#define PG_STASH_ADVICE_H
+
+#include "lib/dshash.h"
+#include "storage/lwlock.h"
+
+/*
+ * The key that we use to find a particular stash entry.
+ */
+typedef struct pgsa_entry_key
+{
+	uint64		pgsa_stash_id;
+	int64		queryId;
+} pgsa_entry_key;
+
+/*
+ * A single stash entry.
+ */
+typedef struct pgsa_entry
+{
+	pgsa_entry_key key;
+	dsa_pointer advice_string;
+} pgsa_entry;
+
+/*
+ * The stash itself is just a mapping from a name to a stash ID.
+ */
+typedef struct pgsa_stash
+{
+	char		name[NAMEDATALEN];
+	uint64		pgsa_stash_id;
+} pgsa_stash;
+
+/*
+ * Top-level shared state object for pg_stash_advice.
+ */
+typedef struct pgsa_shared_state
+{
+	LWLock		lock;
+	int			dsa_tranche;
+	int			stash_tranche;
+	int			entry_tranche;
+	uint64		next_stash_id;
+	dsa_handle	area;
+	dshash_table_handle stash_hash;
+	dshash_table_handle entry_hash;
+} pgsa_shared_state;
+
+/* For stash ID -> stash name hash table */
+typedef struct pgsa_stash_name
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	char	   *name;
+} pgsa_stash_name;
+
+/* Declare stash ID -> stash name hash table */
+#define SH_PREFIX pgsa_stash_name_table
+#define SH_ELEMENT_TYPE pgsa_stash_name
+#define SH_KEY_TYPE uint64
+#define SH_SCOPE extern
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/* Shared memory pointers */
+extern pgsa_shared_state *pgsa_state;
+extern dsa_area *pgsa_dsa_area;
+extern dshash_table *pgsa_stash_dshash;
+extern dshash_table *pgsa_entry_dshash;
+
+/* Function prototypes */
+extern void pgsa_attach(void);
+extern void pgsa_check_stash_name(char *stash_name);
+extern void pgsa_clear_advice_string(char *stash_name, int64 queryId);
+extern void pgsa_create_stash(char *stash_name);
+extern void pgsa_drop_stash(char *stash_name);
+extern uint64 pgsa_lookup_stash_id(char *stash_name);
+extern void pgsa_set_advice_string(char *stash_name, int64 queryId,
+								   char *advice_string);
+
+#endif
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
new file mode 100644
index 00000000000..f047a2d1a09
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql
@@ -0,0 +1,150 @@
+CREATE EXTENSION pg_stash_advice;
+SET compute_query_id = on;
+SET max_parallel_workers_per_gather = 0;
+
+-- Helper: extract query identifier from EXPLAIN VERBOSE output.
+CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+    qid bigint;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+            RETURN qid;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim1 (id, dim1, val1)
+	SELECT g, 'some filler text ' || g, (g % 3) + 1
+	  FROM generate_series(1,100) g;
+VACUUM ANALYZE aa_dim1;
+
+CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int)
+	WITH (autovacuum_enabled = false);
+INSERT INTO aa_dim2 (id, dim2, val2)
+	SELECT g, 'some filler text ' || g, (g % 7) + 1
+	  FROM generate_series(1,1000) g;
+VACUUM ANALYZE aa_dim2;
+
+CREATE TABLE aa_fact (
+	id int primary key,
+	dim1_id integer not null references aa_dim1 (id),
+	dim2_id integer not null references aa_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO aa_fact
+	SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE aa_fact;
+
+-- Get the query identifier.
+SELECT get_query_id($$
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+$$) AS qid \gset
+
+-- Create an advice stash and point pg_stash_advice at it.
+SELECT pg_create_advice_stash('regress_stash');
+SET pg_stash_advice.stash_name = 'regress_stash';
+
+-- Run our test query for the first time with no stashed advice.
+EXPLAIN (COSTS OFF)
+SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an index scan on dim1
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'INDEX_SCAN(d1 aa_dim1_pkey)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join order
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'join_order(f d1 d2)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Force an alternative join strategy
+SELECT pg_set_stashed_advice('regress_stash', :'qid',
+	'NESTED_LOOP_PLAIN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Add a useless extra entry to our test stash. Shouldn't change the result
+-- from the previous test.
+-- (If we're unlucky enough that this ever fails due to query ID actually
+-- being 1, then just put some other constant here. Seems unlikely.)
+SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)');
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Try an empty stash to be sure it does nothing
+SELECT pg_create_advice_stash('regress_empty_stash');
+SET pg_stash_advice.stash_name = 'regress_empty_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+
+-- Test that we can list each stash individually and all of them together,
+-- but not a nonexistent stash.
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY advice_string;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('regress_empty_stash')
+	 ORDER BY advice_string;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents(NULL) ORDER BY advice_string;
+SELECT stash_name, advice_string
+	 FROM pg_get_advice_stash_contents('no_such_stash')
+	 ORDER BY advice_string;
+
+-- Test that we can remove advice.
+SELECT pg_set_stashed_advice('regress_stash', :'qid', null);
+SET pg_stash_advice.stash_name = 'regress_stash';
+EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
+	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
+	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
+	WHERE val1 = 1 AND val2 = 1;
+SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
+SELECT stash_name, advice_string
+	FROM pg_get_advice_stash_contents('regress_stash') ORDER BY advice_string;
+
+-- Can't create a stash that already exists, or drop one that doesn't.
+SELECT pg_create_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('no_such_stash');
+
+-- Can't add to or remove from a stash that does not exist.
+SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)');
+SELECT pg_set_stashed_advice('no_such_stash', 1, null);
+
+-- Can't use query ID 0.
+SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)');
+
+-- Stash names must be non-empty, ASCII, and not too long, and must look
+-- like identifiers.
+SELECT pg_create_advice_stash('');
+SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+SELECT pg_create_advice_stash('   ');
+SET pg_stash_advice.stash_name = '99bottles';
+
+-- Clean up state in dynamic shared memory.
+SELECT pg_drop_advice_stash('regress_stash');
+SELECT pg_drop_advice_stash('regress_empty_stash');
diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice_utf8.sql b/contrib/pg_stash_advice/sql/pg_stash_advice_utf8.sql
new file mode 100644
index 00000000000..13ba635267f
--- /dev/null
+++ b/contrib/pg_stash_advice/sql/pg_stash_advice_utf8.sql
@@ -0,0 +1,16 @@
+/*
+ * This test must be run in a database with UTF-8 encoding,
+ * because other encodings don't support all the characters used.
+ */
+
+SELECT getdatabaseencoding() <> 'UTF8'
+       AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+
+SET client_encoding = utf8;
+
+-- Non-ASCII stash names should be rejected.
+SELECT pg_create_advice_stash('café');
+SET pg_stash_advice.stash_name = 'café';
diff --git a/contrib/pg_stash_advice/stashfuncs.c b/contrib/pg_stash_advice/stashfuncs.c
new file mode 100644
index 00000000000..d8c669d6ab7
--- /dev/null
+++ b/contrib/pg_stash_advice/stashfuncs.c
@@ -0,0 +1,307 @@
+/*-------------------------------------------------------------------------
+ *
+ * stashfuncs.c
+ *	  SQL interface to pg_stash_advice
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/stashfuncs.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/hashfn.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "pg_stash_advice.h"
+#include "utils/builtins.h"
+#include "utils/tuplestore.h"
+
+PG_FUNCTION_INFO_V1(pg_create_advice_stash);
+PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
+PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
+PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
+PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+
+typedef struct pgsa_stash_count
+{
+	uint32		status;
+	uint64		pgsa_stash_id;
+	int64		num_entries;
+} pgsa_stash_count;
+
+#define SH_PREFIX pgsa_stash_count_table
+#define SH_ELEMENT_TYPE pgsa_stash_count
+#define SH_KEY_TYPE uint64
+#define SH_KEY pgsa_stash_id
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64))
+#define SH_EQUAL(tb, a, b) (a == b)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+/*
+ * SQL-callable function to create an advice stash
+ */
+Datum
+pg_create_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_create_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to drop an advice stash
+ */
+Datum
+pg_drop_advice_stash(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+	pgsa_check_stash_name(stash_name);
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_drop_stash(stash_name);
+	LWLockRelease(&pgsa_state->lock);
+	PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to provide a list of advice stashes
+ */
+Datum
+pg_get_advice_stashes(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	pgsa_entry *entry;
+	pgsa_stash *stash;
+	pgsa_stash_count_table_hash *chash;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Tally up the number of entries per stash. */
+	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
+	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		pgsa_stash_count *c;
+		bool		found;
+
+		c = pgsa_stash_count_table_insert(chash,
+										  entry->key.pgsa_stash_id,
+										  &found);
+		if (!found)
+			c->num_entries = 1;
+		else
+			c->num_entries++;
+	}
+	dshash_seq_term(&iterator);
+
+	/* Emit results. */
+	dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[2];
+		bool		nulls[2];
+		pgsa_stash_count *c;
+
+		values[0] = CStringGetTextDatum(stash->name);
+		nulls[0] = false;
+
+		c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id);
+		values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries);
+		nulls[1] = false;
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to provide advice stash contents
+ */
+Datum
+pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	dshash_seq_status iterator;
+	char	   *stash_name = NULL;
+	pgsa_stash_name_table_hash *nhash = NULL;
+	uint64		stash_id = 0;
+	pgsa_entry *entry;
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* User can pass NULL for all stashes, or the name of a specific stash. */
+	if (!PG_ARGISNULL(0))
+	{
+		stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+		pgsa_check_stash_name(stash_name);
+		stash_id = pgsa_lookup_stash_id(stash_name);
+
+		/* If the user specified a stash name, it should exist. */
+		if (stash_id == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("advice stash \"%s\" does not exist", stash_name));
+	}
+	else
+	{
+		pgsa_stash *stash;
+
+		/*
+		 * If we're dumping data about all stashes, we need an ID->name lookup
+		 * table.
+		 */
+		nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL);
+		dshash_seq_init(&iterator, pgsa_stash_dshash, true);
+		while ((stash = dshash_seq_next(&iterator)) != NULL)
+		{
+			pgsa_stash_name *n;
+			bool		found;
+
+			n = pgsa_stash_name_table_insert(nhash,
+											 stash->pgsa_stash_id,
+											 &found);
+			Assert(!found);
+			n->name = pstrdup(stash->name);
+		}
+		dshash_seq_term(&iterator);
+	}
+
+	/* Now iterate over all the entries. */
+	dshash_seq_init(&iterator, pgsa_entry_dshash, false);
+	while ((entry = dshash_seq_next(&iterator)) != NULL)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		char	   *this_stash_name;
+		char	   *advice_string;
+
+		/* Skip incomplete entries where the advice string was never set. */
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		if (stash_id != 0)
+		{
+			/*
+			 * We're only dumping data for one particular stash, so skip
+			 * entries for any other stash and use the stash name specified by
+			 * the user.
+			 */
+			if (stash_id != entry->key.pgsa_stash_id)
+				continue;
+			this_stash_name = stash_name;
+		}
+		else
+		{
+			pgsa_stash_name *n;
+
+			/*
+			 * We're dumping data for all stashes, so look up the correct name
+			 * to use in the hash table. If nothing is found, which is
+			 * possible due to race conditions, make up a string to use.
+			 */
+			n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id);
+			if (n != NULL)
+				this_stash_name = n->name;
+			else
+				this_stash_name = psprintf("<stash %" PRIu64 ">",
+										   entry->key.pgsa_stash_id);
+		}
+
+		/* Work out tuple values. */
+		values[0] = CStringGetTextDatum(this_stash_name);
+		nulls[0] = false;
+		values[1] = Int64GetDatum(entry->key.queryId);
+		nulls[1] = false;
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+		values[2] = CStringGetTextDatum(advice_string);
+		nulls[2] = false;
+
+		/* Emit the tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values,
+							 nulls);
+	}
+	dshash_seq_term(&iterator);
+
+	return (Datum) 0;
+}
+
+/*
+ * SQL-callable function to update an advice stash entry for a particular
+ * query ID
+ *
+ * If the second argument is NULL, we delete any existing advice stash
+ * entry; otherwise, we either create an entry or update it with the new
+ * advice string.
+ */
+Datum
+pg_set_stashed_advice(PG_FUNCTION_ARGS)
+{
+	char	   *stash_name;
+	int64		queryId;
+
+	if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+		PG_RETURN_NULL();
+
+	/* Get and check advice stash name. */
+	stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+	pgsa_check_stash_name(stash_name);
+
+	/*
+	 * Get and check query ID.
+	 *
+	 * queryID 0 means no query ID was computed, so reject that.
+	 */
+	queryId = PG_GETARG_INT64(1);
+	if (queryId == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("cannot set advice string for query ID 0"));
+
+	/* Attach to dynamic shared memory if not already done. */
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	/* Now call the appropriate function to do the real work. */
+	if (PG_ARGISNULL(2))
+	{
+		LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+		pgsa_clear_advice_string(stash_name, queryId);
+		LWLockRelease(&pgsa_state->lock);
+	}
+	else
+	{
+		char	   *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+		LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+		pgsa_set_advice_string(stash_name, queryId, advice_string);
+		LWLockRelease(&pgsa_state->lock);
+	}
+
+	PG_RETURN_VOID();
+}
+
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index bdd4865f53f..b9b03654aad 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -159,6 +159,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &pgplanadvice;
  &pgprewarm;
  &pgrowlocks;
+ &pgstashadvice;
  &pgstatstatements;
  &pgstattuple;
  &pgsurgery;
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index d90b4338d2a..e8f758fc24b 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -144,6 +144,7 @@
 <!ENTITY oid2name        SYSTEM "oid2name.sgml">
 <!ENTITY pageinspect     SYSTEM "pageinspect.sgml">
 <!ENTITY passwordcheck   SYSTEM "passwordcheck.sgml">
+<!ENTITY pgstashadvice   SYSTEM "pgstashadvice.sgml">
 <!ENTITY pgbuffercache   SYSTEM "pgbuffercache.sgml">
 <!ENTITY pgcrypto        SYSTEM "pgcrypto.sgml">
 <!ENTITY pgfreespacemap  SYSTEM "pgfreespacemap.sgml">
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
new file mode 100644
index 00000000000..ec60552a447
--- /dev/null
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -0,0 +1,216 @@
+<!-- doc/src/sgml/pgstashadvice.sgml -->
+
+<sect1 id="pgstashadvice" xreflabel="pg_stash_advice">
+ <title>pg_stash_advice &mdash; store and automatically apply plan advice</title>
+
+ <indexterm zone="pgstashadvice">
+  <primary>pg_stash_advice</primary>
+ </indexterm>
+
+ <para>
+  The <filename>pg_stash_advice</filename> extension allows you to stash
+  <link linkend="pgplanadvice">plan advice</link> strings in dynamic
+  shared memory where they can be automatically applied. An
+  <literal>advice stash</literal> is a mapping from
+  <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
+  strings. Whenever a session is asked to plan a query whose query ID appears
+  in the relevant advice stash, the plan advice string is automatically applied
+  to guide planning. Note that advice stashes exist purely in memory. This
+  means both that it is important to be mindful of memory consumption when
+  deciding how much plan advice to stash, and also that advice stashes must
+  be recreated and repopulated whenever the server is restarted.
+ </para>
+
+ <para>
+  In order to use this module, you will need to execute
+  <literal>CREATE EXTENSION pg_stash_advice</literal> in at least
+  one database, so that you have access to the SQL functions to manage
+  advice stashes. You will also need the <literal>pg_stash_advice</literal>
+  module to be loaded in all sessions where you want this module to
+  automatically apply advice. It will usually be best to do this by adding
+  <literal>pg_stash_advice</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> and restarting the server.
+ </para>
+
+ <para>
+  Once you have met the above criteria, you can create advice stashes
+  using the <literal>pg_create_advice_stash</literal> function described
+  below and set the plan advice for a given query ID in a given stash using
+  the <literal>pg_set_stashed_advice</literal> function. Then, you need
+  only configure <literal>pg_stash_advice.stash_name</literal> to point
+  to the chosen advice stash name. For some use cases, rather than setting
+  this on a system-wide basis, you may find it helpful to use
+  <literal>ALTER DATABASE ... SET</literal> or
+  <literal>ALTER ROLE ... SET</literal> to configure values that will apply
+  only to a database or only to a certain role. Likewise, it may sometimes
+  be better to set the stash name in a particular session using
+  <literal>SET</literal>.
+ </para>
+
+ <para>
+  Because <literal>pg_stash_advice</literal> works on the basis of query
+  identifiers, you will need to determine the query identifier for each query
+  whose plan you wish to control. You will also need to determine the advice
+  string that you wish to store for each query. One way to do this is to use
+  <literal>EXPLAIN</literal>: the <literal>VERBOSE</literal> option will
+  show the query ID, and the <literal>PLAN_ADVICE</literal> option will
+  show plan advice.  Query identifiers can also be obtained through tools
+  such as <xref linkend="pgstatstatements" /> or
+  <xref linkend="monitoring-pg-stat-activity-view" />, but these tools
+  will not provide plan advice strings. Note that
+  <xref linkend="guc-compute-query-id" /> must be enabled for query
+  identifiers to be computed; if set to <literal>auto</literal>, loading
+  <literal>pg_stash_advice</literal> will enable it automatically.
+ </para>
+
+ <para>
+  Generally, the fact that the planner is able to change query plans as
+  the underlying distribution of data changes is a feature, not a bug.
+  Moreover, applying plan advice can have a noticeable performance cost even
+  when it does not result in a change to the query plan. Therefore, it is
+  a good idea to use this feature only when and to the extent needed.
+  Plan advice strings can be trimmed down to mention only those aspects
+  of the plan that need to be controlled, and used only for queries where
+  there is believed to be a significant risk of planner error.
+ </para>
+
+ <para>
+  Note that <literal>pg_stash_advice</literal> currently lacks a sophisticated
+  security model. Only the superuser, or a user to whom the superuser has
+  granted <literal>EXECUTE</literal> permission on the relevant functions,
+  may create advice stashes or alter their contents, but any user may set
+  <literal>pg_stash_advice.stash_name</literal> for their session, and this
+  may reveal the contents of any advice stash with that name. Users should
+  assume that information embedded in stashed advice strings may become visible
+  to nonprivileged users.
+ </para>
+
+ <sect2 id="pgstashadvice-functions">
+  <title>Functions</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <function>pg_create_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_create_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Creates a new, empty advice stash with the given name.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_drop_advice_stash(stash_name text) returns void</function>
+     <indexterm>
+      <primary>pg_drop_advice_stash</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Drops the named advice stash and all of its entries.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_set_stashed_advice(stash_name text, query_id bigint,
+       advice_string text) returns void</function>
+     <indexterm>
+      <primary>pg_set_stashed_advice</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Stores an advice string in the named advice stash, associated with
+      the given query identifier. If an entry for that query identifier
+      already exists in the stash, it is replaced. If
+      <parameter>advice_string</parameter> is <literal>NULL</literal>,
+      any existing entry for that query identifier is removed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stashes() returns setof (stash_name text,
+       num_entries bigint)</function>
+     <indexterm>
+      <primary>pg_get_advice_stashes</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each advice stash, showing the stash name and
+      the number of entries it contains.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>pg_get_advice_stash_contents(stash_name text) returns setof
+       (stash_name text, query_id bigint, advice_string text)</function>
+     <indexterm>
+      <primary>pg_get_advice_stash_contents</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Returns one row for each entry in the named advice stash. If
+      <parameter>stash_name</parameter> is <literal>NULL</literal>, returns
+      entries from all stashes.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-config-params">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.stash_name</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the name of the advice stash to consult during query
+      planning. The default value is the empty string, which disables
+      this module.
+     </para>
+    </listitem>
+   </varlistentry>
+
+  </variablelist>
+
+ </sect2>
+
+ <sect2 id="pgstashadvice-author">
+  <title>Author</title>
+
+  <para>
+   Robert Haas <email>[email protected]</email>
+  </para>
+ </sect2>
+
+</sect1>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 5bc517602b1..7f6f79875ed 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4058,6 +4058,12 @@ pgpa_trove_lookup_type
 pgpa_trove_result
 pgpa_trove_slice
 pgpa_unrolled_join
+pgsa_entry
+pgsa_entry_key
+pgsa_shared_state
+pgsa_stash
+pgsa_stash_count
+pgsa_stash_name
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



  [application/octet-stream] v25-0002-pg_stash_advice-Allow-stashed-advice-to-be-persi.patch (46.2K, 3-v25-0002-pg_stash_advice-Allow-stashed-advice-to-be-persi.patch)
  download | inline diff:
From 5c023afac8a5aef5f751bc5bf385f6f2e4401e73 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Thu, 2 Apr 2026 21:49:02 -0400
Subject: [PATCH v25 2/2] pg_stash_advice: Allow stashed advice to be persisted
 to disk.

If pg_stash_advice.persist = true, stashed advice will be written to
pg_stash_advice.tsv in the data directory, periodically and at
shutdown. On restart, stash modifications are locked out until this
file has been reloaded, but queries will not be, so there may be a
short window after startup during which previously-stashed advice is
not automatically applied.

Author: Robert Haas <[email protected]>
Co-authored-by: <[email protected]>
---
 contrib/pg_stash_advice/Makefile              |   4 +-
 contrib/pg_stash_advice/meson.build           |   8 +-
 .../pg_stash_advice/pg_stash_advice--1.0.sql  |   6 +
 contrib/pg_stash_advice/pg_stash_advice.c     | 172 +++-
 contrib/pg_stash_advice/pg_stash_advice.h     |  12 +
 contrib/pg_stash_advice/stashfuncs.c          |  40 +
 contrib/pg_stash_advice/stashpersist.c        | 799 ++++++++++++++++++
 contrib/pg_stash_advice/t/001_persist.pl      |  84 ++
 doc/src/sgml/pgstashadvice.sgml               |  70 +-
 src/tools/pgindent/typedefs.list              |   4 +
 10 files changed, 1191 insertions(+), 8 deletions(-)
 create mode 100644 contrib/pg_stash_advice/stashpersist.c
 create mode 100644 contrib/pg_stash_advice/t/001_persist.pl

diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile
index f7670c2d4b6..470c07b9dd7 100644
--- a/contrib/pg_stash_advice/Makefile
+++ b/contrib/pg_stash_advice/Makefile
@@ -4,13 +4,15 @@ MODULE_big = pg_stash_advice
 OBJS = \
 	$(WIN32RES) \
 	pg_stash_advice.o \
-	stashfuncs.o
+	stashfuncs.o \
+	stashpersist.o
 
 EXTENSION = pg_stash_advice
 DATA = pg_stash_advice--1.0.sql
 PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice"
 
 REGRESS = pg_stash_advice pg_stash_advice_utf8
+TAP_TESTS = 1
 EXTRA_INSTALL = contrib/pg_plan_advice
 
 ifdef USE_PGXS
diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build
index 8fbcfcf8693..96f485b7729 100644
--- a/contrib/pg_stash_advice/meson.build
+++ b/contrib/pg_stash_advice/meson.build
@@ -2,7 +2,8 @@
 
 pg_stash_advice_sources = files(
   'pg_stash_advice.c',
-  'stashfuncs.c'
+  'stashfuncs.c',
+  'stashpersist.c'
 )
 
 if host_system == 'windows'
@@ -34,4 +35,9 @@ tests += {
       'pg_stash_advice_utf8',
     ],
   },
+  'tap': {
+    'tests': [
+      't/001_persist.pl',
+    ],
+  },
 }
diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
index 88dedd8ef1b..50f12dac313 100644
--- a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
+++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql
@@ -36,8 +36,14 @@ RETURNS SETOF record
 AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents'
 LANGUAGE C;
 
+CREATE FUNCTION pg_start_stash_advice_worker()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_start_stash_advice_worker'
+LANGUAGE C STRICT;
+
 REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC;
 REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC;
 REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC;
 REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC;
 REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION pg_start_stash_advice_worker() FROM PUBLIC;
diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
index 15e7adf849b..1858c6a135a 100644
--- a/contrib/pg_stash_advice/pg_stash_advice.c
+++ b/contrib/pg_stash_advice/pg_stash_advice.c
@@ -13,9 +13,11 @@
 
 #include "common/hashfn.h"
 #include "common/string.h"
+#include "miscadmin.h"
 #include "nodes/queryjumble.h"
 #include "pg_plan_advice.h"
 #include "pg_stash_advice.h"
+#include "postmaster/bgworker.h"
 #include "storage/dsm_registry.h"
 #include "utils/guc.h"
 #include "utils/memutils.h"
@@ -41,12 +43,14 @@ static dshash_parameters pgsa_entry_dshash_parameters = {
 	LWTRANCHE_INVALID			/* gets set at runtime */
 };
 
-/* GUC variable */
+/* GUC variables */
 static char *pg_stash_advice_stash_name = "";
+bool		pg_stash_advice_persist = true;
+int			pg_stash_advice_persist_interval = 30;
 
 /* Shared memory pointers */
 pgsa_shared_state *pgsa_state;
-dsa_area *pgsa_dsa_area;
+dsa_area   *pgsa_dsa_area;
 dshash_table *pgsa_stash_dshash;
 dshash_table *pgsa_entry_dshash;
 
@@ -87,6 +91,33 @@ _PG_init(void)
 	EnableQueryId();
 
 	/* Define our GUCs. */
+	if (process_shared_preload_libraries_in_progress)
+		DefineCustomBoolVariable("pg_stash_advice.persist",
+								 "Save and restore advice stash contents across restarts.",
+								 NULL,
+								 &pg_stash_advice_persist,
+								 true,
+								 PGC_POSTMASTER,
+								 0,
+								 NULL,
+								 NULL,
+								 NULL);
+	else
+		pg_stash_advice_persist = false;
+
+	DefineCustomIntVariable("pg_stash_advice.persist_interval",
+							"Interval between advice stash saves, in seconds.",
+							NULL,
+							&pg_stash_advice_persist_interval,
+							30,
+							0,
+							3600,
+							PGC_SIGHUP,
+							GUC_UNIT_S,
+							NULL,
+							NULL,
+							NULL);
+
 	DefineCustomStringVariable("pg_stash_advice.stash_name",
 							   "Name of the advice stash to be used in this session.",
 							   NULL,
@@ -100,6 +131,10 @@ _PG_init(void)
 
 	MarkGUCPrefixReserved("pg_stash_advice");
 
+	/* Start the background worker for persistence, if enabled. */
+	if (pg_stash_advice_persist)
+		pgsa_start_worker();
+
 	/* Tell pg_plan_advice that we want to provide advice strings. */
 	add_advisor_fn =
 		load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor",
@@ -131,6 +166,10 @@ pgsa_advisor(PlannerGlobal *glob, Query *parse,
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
 
+	/* If stash data is still being restored from disk, ignore. */
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+		return NULL;
+
 	/*
 	 * Translate pg_stash_advice.stash_name to an integer ID.
 	 *
@@ -279,6 +318,19 @@ pgsa_attach(void)
 	MemoryContextSwitchTo(oldcontext);
 }
 
+/*
+ * Error out if the stashes have not been loaded from disk yet.
+ */
+void
+pgsa_check_lockout(void)
+{
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("stash modifications are not allowed because \"%s\" has not been loaded yet",
+						PGSA_DUMP_FILE)));
+}
+
 /*
  * Check whether an advice stash name is legal, and signal an error if not.
  *
@@ -383,6 +435,9 @@ pgsa_create_stash(char *stash_name)
 				errmsg("advice stash \"%s\" already exists", stash_name));
 	stash->pgsa_stash_id = pgsa_state->next_stash_id++;
 	dshash_release_lock(pgsa_stash_dshash, stash);
+
+	/* Bump change count. */
+	pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
 }
 
 /*
@@ -423,6 +478,9 @@ pgsa_clear_advice_string(char *stash_name, int64 queryId)
 	/* Now we free the advice string as well, if there was one. */
 	if (old_dp != InvalidDsaPointer)
 		dsa_free(pgsa_dsa_area, old_dp);
+
+	/* Bump change count. */
+	pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
 }
 
 /*
@@ -464,6 +522,43 @@ pgsa_drop_stash(char *stash_name)
 		}
 	}
 	dshash_seq_term(&iterator);
+
+	/* Bump change count. */
+	pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
+}
+
+/*
+ * Remove all stashes and entries from shared memory.
+ *
+ * This is intended to be called before reloading from a dump file, so that
+ * a failed previous attempt doesn't leave stale data behind.
+ */
+void
+pgsa_reset_all_stashes(void)
+{
+	dshash_seq_status iter;
+	pgsa_entry *entry;
+
+	Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE));
+
+	/* Remove all stashes. */
+	dshash_seq_init(&iter, pgsa_stash_dshash, true);
+	while (dshash_seq_next(&iter) != NULL)
+		dshash_delete_current(&iter);
+	dshash_seq_term(&iter);
+
+	/* Remove all entries. */
+	dshash_seq_init(&iter, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iter)) != NULL)
+	{
+		if (entry->advice_string != InvalidDsaPointer)
+			dsa_free(pgsa_dsa_area, entry->advice_string);
+		dshash_delete_current(&iter);
+	}
+	dshash_seq_term(&iter);
+
+	/* Reset the stash ID counter. */
+	pgsa_state->next_stash_id = UINT64CONST(1);
 }
 
 /*
@@ -483,6 +578,23 @@ pgsa_init_shared_state(void *ptr, void *arg)
 	state->area = DSA_HANDLE_INVALID;
 	state->stash_hash = DSHASH_HANDLE_INVALID;
 	state->entry_hash = DSHASH_HANDLE_INVALID;
+	state->bgworker_pid = InvalidPid;
+	pg_atomic_init_flag(&state->stashes_ready);
+	pg_atomic_init_u64(&state->change_count, 0);
+
+	/*
+	 * If this module was loaded via shared_preload_libraries, then
+	 * pg_stash_advice_persist is a GUC variable. If it's true, that means
+	 * that we should lock out manual stash modifications until the dump file
+	 * has been successfully loaded. If it's false, there's nothing to load,
+	 * so we set stashes_ready immediately.
+	 *
+	 * If this module was not loaded via shared_preload_libraries, then
+	 * pg_stash_advice_persist is not a GUC variable, but it will be false,
+	 * which leads to the correct behavior.
+	 */
+	if (!pg_stash_advice_persist)
+		pg_atomic_test_set_flag(&state->stashes_ready);
 }
 
 /*
@@ -602,4 +714,60 @@ pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string)
 	 */
 	if (DsaPointerIsValid(old_dp))
 		dsa_free(pgsa_dsa_area, old_dp);
+
+	/* Bump change count. */
+	pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
+}
+
+/*
+ * Start our worker process.
+ */
+void
+pgsa_start_worker(void)
+{
+	BackgroundWorker worker = {0};
+	BackgroundWorkerHandle *handle;
+	BgwHandleStatus status;
+	pid_t		pid;
+
+	worker.bgw_flags = BGWORKER_SHMEM_ACCESS;
+	worker.bgw_start_time = BgWorkerStart_ConsistentState;
+	worker.bgw_restart_time = BGW_DEFAULT_RESTART_INTERVAL;
+	strcpy(worker.bgw_library_name, "pg_stash_advice");
+	strcpy(worker.bgw_function_name, "pg_stash_advice_worker_main");
+	strcpy(worker.bgw_name, "pg_stash_advice worker");
+	strcpy(worker.bgw_type, "pg_stash_advice worker");
+
+	/*
+	 * If process_shared_preload_libraries_in_progress = true, we may be in
+	 * the postmaster, in which case this will really register the worker, or
+	 * we may be in a child process in an EXEC_BACKEND build, in which case it
+	 * will silently do nothing (which is the correct behavior).
+	 */
+	if (process_shared_preload_libraries_in_progress)
+	{
+		RegisterBackgroundWorker(&worker);
+		return;
+	}
+
+	/*
+	 * If process_shared_preload_libraries_in_progress = false, we're being
+	 * asked to start the worker after system startup time. In other words,
+	 * unless this is single-user mode, we're not in the postmaster, so we
+	 * should use RegisterDynamicBackgroundWorker and then wait for startup to
+	 * complete. (If we do happen to be in single-user mode, this will error
+	 * out, which is fine.)
+	 */
+	worker.bgw_notify_pid = MyProcPid;
+	if (!RegisterDynamicBackgroundWorker(&worker, &handle))
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_RESOURCES),
+				 errmsg("could not register background process"),
+				 errhint("You may need to increase \"max_worker_processes\".")));
+	status = WaitForBackgroundWorkerStartup(handle, &pid);
+	if (status != BGWH_STARTED)
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_RESOURCES),
+				 errmsg("could not start background process"),
+				 errhint("More details may be available in the server log.")));
 }
diff --git a/contrib/pg_stash_advice/pg_stash_advice.h b/contrib/pg_stash_advice/pg_stash_advice.h
index eeaa61e0f37..01aded472f3 100644
--- a/contrib/pg_stash_advice/pg_stash_advice.h
+++ b/contrib/pg_stash_advice/pg_stash_advice.h
@@ -22,6 +22,8 @@
 #include "lib/dshash.h"
 #include "storage/lwlock.h"
 
+#define PGSA_DUMP_FILE		"pg_stash_advice.tsv"
+
 /*
  * The key that we use to find a particular stash entry.
  */
@@ -62,6 +64,9 @@ typedef struct pgsa_shared_state
 	dsa_handle	area;
 	dshash_table_handle stash_hash;
 	dshash_table_handle entry_hash;
+	pid_t		bgworker_pid;
+	pg_atomic_flag stashes_ready;
+	pg_atomic_uint64 change_count;
 } pgsa_shared_state;
 
 /* For stash ID -> stash name hash table */
@@ -86,14 +91,21 @@ extern dsa_area *pgsa_dsa_area;
 extern dshash_table *pgsa_stash_dshash;
 extern dshash_table *pgsa_entry_dshash;
 
+/* GUC variables */
+extern bool pg_stash_advice_persist;
+extern int	pg_stash_advice_persist_interval;
+
 /* Function prototypes */
 extern void pgsa_attach(void);
+extern void pgsa_check_lockout(void);
 extern void pgsa_check_stash_name(char *stash_name);
 extern void pgsa_clear_advice_string(char *stash_name, int64 queryId);
 extern void pgsa_create_stash(char *stash_name);
 extern void pgsa_drop_stash(char *stash_name);
 extern uint64 pgsa_lookup_stash_id(char *stash_name);
+extern void pgsa_reset_all_stashes(void);
 extern void pgsa_set_advice_string(char *stash_name, int64 queryId,
 								   char *advice_string);
+extern void pgsa_start_worker(void);
 
 #endif
diff --git a/contrib/pg_stash_advice/stashfuncs.c b/contrib/pg_stash_advice/stashfuncs.c
index d8c669d6ab7..77f8e19e867 100644
--- a/contrib/pg_stash_advice/stashfuncs.c
+++ b/contrib/pg_stash_advice/stashfuncs.c
@@ -14,6 +14,7 @@
 #include "common/hashfn.h"
 #include "fmgr.h"
 #include "funcapi.h"
+#include "miscadmin.h"
 #include "pg_stash_advice.h"
 #include "utils/builtins.h"
 #include "utils/tuplestore.h"
@@ -23,6 +24,7 @@ PG_FUNCTION_INFO_V1(pg_drop_advice_stash);
 PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents);
 PG_FUNCTION_INFO_V1(pg_get_advice_stashes);
 PG_FUNCTION_INFO_V1(pg_set_stashed_advice);
+PG_FUNCTION_INFO_V1(pg_start_stash_advice_worker);
 
 typedef struct pgsa_stash_count
 {
@@ -53,6 +55,7 @@ pg_create_advice_stash(PG_FUNCTION_ARGS)
 	pgsa_check_stash_name(stash_name);
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
+	pgsa_check_lockout();
 	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
 	pgsa_create_stash(stash_name);
 	LWLockRelease(&pgsa_state->lock);
@@ -70,6 +73,7 @@ pg_drop_advice_stash(PG_FUNCTION_ARGS)
 	pgsa_check_stash_name(stash_name);
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
+	pgsa_check_lockout();
 	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
 	pgsa_drop_stash(stash_name);
 	LWLockRelease(&pgsa_state->lock);
@@ -94,6 +98,10 @@ pg_get_advice_stashes(PG_FUNCTION_ARGS)
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
 
+	/* If stash data is still being restored from disk, ignore. */
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+		return (Datum) 0;
+
 	/* Tally up the number of entries per stash. */
 	chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL);
 	dshash_seq_init(&iterator, pgsa_entry_dshash, true);
@@ -154,6 +162,10 @@ pg_get_advice_stash_contents(PG_FUNCTION_ARGS)
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
 
+	/* If stash data is still being restored from disk, ignore. */
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+		return (Datum) 0;
+
 	/* User can pass NULL for all stashes, or the name of a specific stash. */
 	if (!PG_ARGISNULL(0))
 	{
@@ -286,6 +298,9 @@ pg_set_stashed_advice(PG_FUNCTION_ARGS)
 	if (unlikely(pgsa_entry_dshash == NULL))
 		pgsa_attach();
 
+	/* Don't allow writes if stash data is still being restored from disk. */
+	pgsa_check_lockout();
+
 	/* Now call the appropriate function to do the real work. */
 	if (PG_ARGISNULL(2))
 	{
@@ -305,3 +320,28 @@ pg_set_stashed_advice(PG_FUNCTION_ARGS)
 	PG_RETURN_VOID();
 }
 
+/*
+ * SQL-callable function to start the persistence background worker.
+ */
+Datum
+pg_start_stash_advice_worker(PG_FUNCTION_ARGS)
+{
+	pid_t		pid;
+
+	if (unlikely(pgsa_entry_dshash == NULL))
+		pgsa_attach();
+
+	LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+	pid = pgsa_state->bgworker_pid;
+	LWLockRelease(&pgsa_state->lock);
+
+	if (pid != InvalidPid)
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("pg_stash_advice worker is already running under PID %d",
+						(int) pid)));
+
+	pgsa_start_worker();
+
+	PG_RETURN_VOID();
+}
diff --git a/contrib/pg_stash_advice/stashpersist.c b/contrib/pg_stash_advice/stashpersist.c
new file mode 100644
index 00000000000..da96ee0d803
--- /dev/null
+++ b/contrib/pg_stash_advice/stashpersist.c
@@ -0,0 +1,799 @@
+/*-------------------------------------------------------------------------
+ *
+ * stashpersist.c
+ *	  Persistence support for pg_stash_advice.
+ *
+ * Copyright (c) 2016-2026, PostgreSQL Global Development Group
+ *
+ *	  contrib/pg_stash_advice/stashpersist.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <sys/stat.h>
+
+#include "common/hashfn.h"
+#include "miscadmin.h"
+#include "pg_stash_advice.h"
+#include "postmaster/bgworker.h"
+#include "postmaster/interrupt.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "storage/latch.h"
+#include "storage/proc.h"
+#include "storage/procsignal.h"
+#include "utils/backend_status.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+#include "utils/timestamp.h"
+
+typedef struct pgsa_writer_context
+{
+	char		pathname[MAXPGPATH];
+	FILE	   *file;
+	pgsa_stash_name_table_hash *nhash;
+	StringInfoData buf;
+	int			entries_written;
+} pgsa_writer_context;
+
+/*
+ * A parsed entry line, with pointers into the slurp buffer.
+ */
+typedef struct pgsa_saved_entry
+{
+	char	   *stash_name;
+	int64		queryId;
+	char	   *advice_string;
+} pgsa_saved_entry;
+
+/*
+ * simplehash for detecting duplicate stash names during parsing.
+ * Keyed by stash name (char *), pointing into the slurp buffer.
+ */
+typedef struct pgsa_saved_stash
+{
+	uint32		status;
+	char	   *name;
+} pgsa_saved_stash;
+
+#define SH_PREFIX pgsa_saved_stash_table
+#define SH_ELEMENT_TYPE pgsa_saved_stash
+#define SH_KEY_TYPE char *
+#define SH_KEY name
+#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) (key), strlen(key))
+#define SH_EQUAL(tb, a, b) (strcmp(a, b) == 0)
+#define SH_SCOPE static inline
+#define SH_DEFINE
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+extern PGDLLEXPORT void pg_stash_advice_worker_main(Datum main_arg);
+static void pgsa_append_tsv_escaped_string(StringInfo buf, const char *str);
+static void pgsa_detach_shmem(int code, Datum arg);
+static char *pgsa_next_tsv_field(char **cursor);
+static void pgsa_read_from_disk(void);
+static void pgsa_restore_entries(pgsa_saved_entry *entries, int num_entries);
+static void pgsa_restore_stashes(pgsa_saved_stash_table_hash *saved_stashes);
+static void pgsa_unescape_tsv_field(char *str, const char *filename,
+									unsigned lineno);
+static void pgsa_write_entries(pgsa_writer_context *wctx);
+pg_noreturn static void pgsa_write_error(pgsa_writer_context *wctx);
+static void pgsa_write_stashes(pgsa_writer_context *wctx);
+static void pgsa_write_to_disk(void);
+
+/*
+ * Background worker entry point for pg_stash_advice persistence.
+ *
+ * On startup, if load_from_disk_pending is set, we load previously saved
+ * stash data from disk.  Then we enter a loop, periodically checking whether
+ * any changes have been made (via the change_count atomic counter) and
+ * writing them to disk.  On shutdown, we perform a final write.
+ */
+PGDLLEXPORT void
+pg_stash_advice_worker_main(Datum main_arg)
+{
+	uint64		last_change_count;
+	TimestampTz last_write_time = 0;
+
+	/* Establish signal handlers; once that's done, unblock signals. */
+	pqsignal(SIGTERM, SignalHandlerForShutdownRequest);
+	pqsignal(SIGHUP, SignalHandlerForConfigReload);
+	pqsignal(SIGUSR1, procsignal_sigusr1_handler);
+	BackgroundWorkerUnblockSignals();
+
+	/* Log a debug message */
+	ereport(DEBUG1,
+			errmsg("pg_stash_advice worker started"));
+
+	/* Set up session user so pgstat can report it. */
+	InitializeSessionUserIdStandalone();
+
+	/* Report this worker in pg_stat_activity. */
+	pgstat_beinit();
+	pgstat_bestart_initial();
+	pgstat_bestart_final();
+
+	/* Attach to shared memory structures. */
+	pgsa_attach();
+
+	/* Set on-detach hook so that our PID will be cleared on exit. */
+	before_shmem_exit(pgsa_detach_shmem, 0);
+
+	/*
+	 * Store our PID in shared memory, unless there's already another worker
+	 * running, in which case just exit.
+	 */
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	if (pgsa_state->bgworker_pid != InvalidPid)
+	{
+		LWLockRelease(&pgsa_state->lock);
+		ereport(LOG,
+				(errmsg("pg_stash_advice worker is already running under PID %d",
+						(int) pgsa_state->bgworker_pid)));
+		return;
+	}
+	pgsa_state->bgworker_pid = MyProcPid;
+	LWLockRelease(&pgsa_state->lock);
+
+	/*
+	 * If pg_stash_advice.persist was set to true during
+	 * process_shared_preload_libraries() and the data has not yet been
+	 * successfully loaded, load it now.
+	 */
+	if (pg_atomic_unlocked_test_flag(&pgsa_state->stashes_ready))
+	{
+		pgsa_read_from_disk();
+		pg_atomic_test_set_flag(&pgsa_state->stashes_ready);
+	}
+
+	/* Note the current change count so we can detect future changes. */
+	last_change_count = pg_atomic_read_u64(&pgsa_state->change_count);
+
+	/* Periodically write to disk until terminated. */
+	while (!ShutdownRequestPending)
+	{
+		/* In case of a SIGHUP, just reload the configuration. */
+		if (ConfigReloadPending)
+		{
+			ConfigReloadPending = false;
+			ProcessConfigFile(PGC_SIGHUP);
+		}
+
+		if (pg_stash_advice_persist_interval <= 0)
+		{
+			/* Only writing at shutdown, so just wait forever. */
+			(void) WaitLatch(MyLatch,
+							 WL_LATCH_SET | WL_EXIT_ON_PM_DEATH,
+							 -1L,
+							 PG_WAIT_EXTENSION);
+		}
+		else
+		{
+			TimestampTz next_write_time;
+			long		delay_in_ms;
+			uint64		current_change_count;
+
+			/* Compute when the next write should happen. */
+			next_write_time =
+				TimestampTzPlusMilliseconds(last_write_time,
+											pg_stash_advice_persist_interval * 1000);
+			delay_in_ms =
+				TimestampDifferenceMilliseconds(GetCurrentTimestamp(),
+												next_write_time);
+
+			/*
+			 * When we reach next_write_time, we always update last_write_time
+			 * (which is really the time at which we last considered writing),
+			 * but we only actually write to disk if something has changed.
+			 */
+			if (delay_in_ms <= 0)
+			{
+				current_change_count =
+					pg_atomic_read_u64(&pgsa_state->change_count);
+				if (current_change_count != last_change_count)
+				{
+					pgsa_write_to_disk();
+					last_change_count = current_change_count;
+				}
+				last_write_time = GetCurrentTimestamp();
+				continue;
+			}
+
+			/* Sleep until the next write time. */
+			(void) WaitLatch(MyLatch,
+							 WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH,
+							 delay_in_ms,
+							 PG_WAIT_EXTENSION);
+		}
+
+		ResetLatch(MyLatch);
+	}
+
+	/* Write one last time before exiting. */
+	pgsa_write_to_disk();
+}
+
+/*
+ * Clear our PID from shared memory on exit.
+ */
+static void
+pgsa_detach_shmem(int code, Datum arg)
+{
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	if (pgsa_state->bgworker_pid == MyProcPid)
+		pgsa_state->bgworker_pid = InvalidPid;
+	LWLockRelease(&pgsa_state->lock);
+}
+
+/*
+ * Load advice stash data from a dump file on disk, if there is one.
+ */
+static void
+pgsa_read_from_disk(void)
+{
+	struct stat statbuf;
+	FILE	   *file;
+	char	   *filebuf;
+	size_t		nread;
+	char	   *p;
+	unsigned	lineno;
+	pgsa_saved_stash_table_hash *saved_stashes;
+	int			num_stashes = 0;
+	pgsa_saved_entry *entries;
+	int			num_entries = 0;
+	int			max_entries = 64;
+	MemoryContext tmpcxt;
+	MemoryContext oldcxt;
+
+	Assert(pgsa_entry_dshash != NULL);
+
+	/*
+	 * Clear any existing shared memory state.
+	 *
+	 * Normally, there won't be any, but if this function was called before
+	 * and failed after beginning to apply changes to shared memory, then we
+	 * need to get rid of any entries created at that time before trying
+	 * again.
+	 */
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_reset_all_stashes();
+	LWLockRelease(&pgsa_state->lock);
+
+	/* Open the dump file. If it doesn't exist, we're done. */
+	file = AllocateFile(PGSA_DUMP_FILE, "r");
+	if (!file)
+	{
+		if (errno == ENOENT)
+			return;
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not open file \"%s\": %m", PGSA_DUMP_FILE)));
+	}
+
+	/* Use a temporary context for all parse-phase allocations. */
+	tmpcxt = AllocSetContextCreate(CurrentMemoryContext,
+								   "pg_stash_advice load",
+								   ALLOCSET_DEFAULT_SIZES);
+	oldcxt = MemoryContextSwitchTo(tmpcxt);
+
+	/* Figure out how long the file is. */
+	if (fstat(fileno(file), &statbuf) != 0)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not stat file \"%s\": %m", PGSA_DUMP_FILE)));
+
+	/*
+	 * Slurp the entire file into memory all at once.
+	 *
+	 * We could avoid this by reading the file incrementally and applying
+	 * changes to pgsa_stash_dshash and pgsa_entry_dshash as we go. Given the
+	 * lockout mechanism implemented by stashes_ready, that shouldn't have any
+	 * user-visible behavioral consequences, but it would consume shared
+	 * memory to no benefit. It seems better to buffer everything in private
+	 * memory first, and then only apply the changes once the file has been
+	 * successfully parsed in its entirety.
+	 *
+	 * That also has the advantage of possibly being more future-proof: if we
+	 * decide to remove the stashes_ready mechanism in the future, or say
+	 * allow for multiple save files, fully validating the file before
+	 * applying any changes will become much more important.
+	 *
+	 * Of course, this approach does have one major disadvantage, which is
+	 * that we'll temporarily use about twice as much memory as we're
+	 * ultimately going to need, but that seems like it shouldn't be a problem
+	 * in practice. If there's so much stashed advice that parsing the disk
+	 * file runs us out of memory, something has gone terribly wrong. In that
+	 * situation, there probably also isn't enough free memory for the
+	 * workload that the advice is attempting to manipulate to run
+	 * successfully.
+	 */
+	filebuf = palloc_extended(statbuf.st_size + 1, MCXT_ALLOC_HUGE);
+	nread = fread(filebuf, 1, statbuf.st_size, file);
+	if (ferror(file))
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not read file \"%s\": %m", PGSA_DUMP_FILE)));
+	FreeFile(file);
+	filebuf[nread] = '\0';
+
+	/* Initial memory allocations. */
+	saved_stashes = pgsa_saved_stash_table_create(tmpcxt, 64, NULL);
+	entries = palloc(max_entries * sizeof(pgsa_saved_entry));
+
+	/*
+	 * For memory and CPU efficiency, we parse the file in place. The end of
+	 * each line gets replaced with a NUL byte, and then the end of each field
+	 * within a line gets the same treatment. The advice string is unescaped
+	 * in place, and stash names and query IDs can't contain any special
+	 * characters. All of the resulting pointers point right back into the
+	 * buffer; we only need additional memory to grow the 'entries' array and
+	 * the 'saved_stashes' hash table.
+	 */
+	for (p = filebuf, lineno = 1; *p != '\0'; lineno++)
+	{
+		char	   *cursor = p;
+		char	   *eol;
+		char	   *line_type;
+
+		/* Find end of line and NUL-terminate. */
+		eol = strchr(p, '\n');
+		if (eol != NULL)
+		{
+			*eol = '\0';
+			p = eol + 1;
+			if (eol > cursor && eol[-1] == '\r')
+				eol[-1] = '\0';
+		}
+		else
+			p += strlen(p);
+
+		/* Skip empty lines. */
+		if (*cursor == '\0')
+			continue;
+
+		/* First field is the type of line, either "stash" or "entry". */
+		line_type = pgsa_next_tsv_field(&cursor);
+		if (strcmp(line_type, "stash") == 0)
+		{
+			char	   *name;
+			bool		found;
+
+			/* Second field should be the stash name. */
+			name = pgsa_next_tsv_field(&cursor);
+			if (name == NULL || *name == '\0')
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected stash name",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* No further fields are expected. */
+			if (*cursor != '\0')
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected end of line",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* Duplicate check. */
+			(void) pgsa_saved_stash_table_insert(saved_stashes, name, &found);
+			if (found)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: duplicate stash name \"%s\"",
+								PGSA_DUMP_FILE, lineno, name)));
+			num_stashes++;
+		}
+		else if (strcmp(line_type, "entry") == 0)
+		{
+			char	   *stash_name;
+			char	   *queryid_str;
+			char	   *advice_str;
+			char	   *endptr;
+			int64		queryId;
+
+			/* Second field should be the stash name. */
+			stash_name = pgsa_next_tsv_field(&cursor);
+			if (stash_name == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected stash name",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* Third field should be the query ID. */
+			queryid_str = pgsa_next_tsv_field(&cursor);
+			if (queryid_str == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected query ID",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* Fourth field should be the advice string. */
+			advice_str = pgsa_next_tsv_field(&cursor);
+			if (advice_str == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected advice string",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* No further fields are expected. */
+			if (*cursor != '\0')
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: expected end of line",
+								PGSA_DUMP_FILE, lineno)));
+
+			/* Make sure the stash is one we've actually seen. */
+			if (pgsa_saved_stash_table_lookup(saved_stashes,
+											  stash_name) == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: unknown stash \"%s\"",
+								PGSA_DUMP_FILE, lineno, stash_name)));
+
+			/* Parse the query ID. */
+			errno = 0;
+			queryId = strtoll(queryid_str, &endptr, 10);
+			if (*endptr != '\0' || errno != 0 || queryid_str == endptr)
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: invalid query ID \"%s\"",
+								PGSA_DUMP_FILE, lineno, queryid_str)));
+
+			/* Unescape the advice string. */
+			pgsa_unescape_tsv_field(advice_str, PGSA_DUMP_FILE, lineno);
+
+			/* Append to the entry array. */
+			if (num_entries >= max_entries)
+			{
+				max_entries *= 2;
+				entries = repalloc(entries,
+								   max_entries * sizeof(pgsa_saved_entry));
+			}
+			entries[num_entries].stash_name = stash_name;
+			entries[num_entries].queryId = queryId;
+			entries[num_entries].advice_string = advice_str;
+			num_entries++;
+		}
+		else
+		{
+			ereport(ERROR,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("syntax error in file \"%s\" line %u: unrecognized line type",
+							PGSA_DUMP_FILE, lineno)));
+		}
+	}
+
+	/*
+	 * Parsing succeeded. Apply everything to shared memory.
+	 *
+	 * At this point, we know that the file we just read is fully valid, but
+	 * it's still possible for this to fail if, for example, DSA memory cannot
+	 * be allocated. If that happens, the worker will die, the postmaster will
+	 * eventually restart it, and we'll try again after clearing any data that
+	 * we did manage to put into shared memory. (Note that we call
+	 * pgsa_reset_all_stashes() at the top of this function.)
+	 */
+	pgsa_restore_stashes(saved_stashes);
+	pgsa_restore_entries(entries, num_entries);
+
+	/* Hooray, it worked! Notify the user. */
+	ereport(LOG,
+			(errmsg("loaded %d advice stashes and %d entries from \"%s\"",
+					num_stashes, num_entries, PGSA_DUMP_FILE)));
+
+	/* Clean up. */
+	MemoryContextSwitchTo(oldcxt);
+	MemoryContextDelete(tmpcxt);
+}
+
+/*
+ * Write all advice stash data to disk.
+ *
+ * The file format is a simple TSV with a line-type prefix:
+ *   stash\tstash_name
+ *   entry\tstash_name\tquery_id\tadvice_string
+ */
+static void
+pgsa_write_to_disk(void)
+{
+	pgsa_writer_context wctx = {0};
+	MemoryContext tmpcxt;
+	MemoryContext oldcxt;
+
+	Assert(pgsa_entry_dshash != NULL);
+
+	/* Use a temporary context so all allocations are freed at the end. */
+	tmpcxt = AllocSetContextCreate(CurrentMemoryContext,
+								   "pg_stash_advice dump",
+								   ALLOCSET_DEFAULT_SIZES);
+	oldcxt = MemoryContextSwitchTo(tmpcxt);
+
+	/* Set up the writer context. */
+	snprintf(wctx.pathname, MAXPGPATH, "%s.tmp", PGSA_DUMP_FILE);
+	wctx.file = AllocateFile(wctx.pathname, "w");
+	if (!wctx.file)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not open file \"%s\": %m", wctx.pathname)));
+	wctx.nhash = pgsa_stash_name_table_create(tmpcxt, 64, NULL);
+	initStringInfo(&wctx.buf);
+
+	/* Write stash lines, then entry lines. */
+	pgsa_write_stashes(&wctx);
+	pgsa_write_entries(&wctx);
+
+	/*
+	 * If nothing was written, remove both the temp file and any existing dump
+	 * file rather than installing a zero-length file.
+	 */
+	if (wctx.nhash->members == 0)
+	{
+		ereport(DEBUG1,
+				errmsg("there are no advice stashes to save"));
+		FreeFile(wctx.file);
+		unlink(wctx.pathname);
+		if (unlink(PGSA_DUMP_FILE) == 0)
+			ereport(DEBUG1,
+					errmsg("removed \"%s\"", PGSA_DUMP_FILE));
+	}
+	else
+	{
+		if (FreeFile(wctx.file) != 0)
+		{
+			int			save_errno = errno;
+
+			unlink(wctx.pathname);
+			errno = save_errno;
+			ereport(ERROR,
+					(errcode_for_file_access(),
+					 errmsg("could not close file \"%s\": %m",
+							wctx.pathname)));
+		}
+		(void) durable_rename(wctx.pathname, PGSA_DUMP_FILE, ERROR);
+
+		ereport(LOG,
+				errmsg("saved %d advice stashes and %d entries to \"%s\"",
+					   (int) wctx.nhash->members, wctx.entries_written,
+					   PGSA_DUMP_FILE));
+	}
+
+	MemoryContextSwitchTo(oldcxt);
+	MemoryContextDelete(tmpcxt);
+}
+
+/*
+ * Append the TSV-escaped form of str to buf.
+ *
+ * Backslash, tab, newline, and carriage return are escaped with backslash
+ * sequences.  All other characters are passed through unchanged.
+ */
+static void
+pgsa_append_tsv_escaped_string(StringInfo buf, const char *str)
+{
+	for (const char *p = str; *p != '\0'; p++)
+	{
+		switch (*p)
+		{
+			case '\\':
+				appendStringInfoString(buf, "\\\\");
+				break;
+			case '\t':
+				appendStringInfoString(buf, "\\t");
+				break;
+			case '\n':
+				appendStringInfoString(buf, "\\n");
+				break;
+			case '\r':
+				appendStringInfoString(buf, "\\r");
+				break;
+			default:
+				appendStringInfoChar(buf, *p);
+				break;
+		}
+	}
+}
+
+/*
+ * Extract the next tab-delimited field from *cursor.
+ *
+ * The tab delimiter is replaced with '\0' and *cursor is advanced past it.
+ * If *cursor already points to '\0' (no more fields), returns NULL.
+ */
+static char *
+pgsa_next_tsv_field(char **cursor)
+{
+	char	   *start = *cursor;
+	char	   *p = start;
+
+	if (*p == '\0')
+		return NULL;
+
+	while (*p != '\0' && *p != '\t')
+		p++;
+
+	if (*p == '\t')
+		*p++ = '\0';
+
+	*cursor = p;
+	return start;
+}
+
+/*
+ * Insert entries into shared memory from the parsed entry array.
+ */
+static void
+pgsa_restore_entries(pgsa_saved_entry *entries, int num_entries)
+{
+	LWLockAcquire(&pgsa_state->lock, LW_SHARED);
+	for (int i = 0; i < num_entries; i++)
+	{
+		ereport(DEBUG2,
+				errmsg("restoring advice stash entry for \"%s\", query ID %" PRId64,
+					   entries[i].stash_name, entries[i].queryId));
+		pgsa_set_advice_string(entries[i].stash_name,
+							   entries[i].queryId,
+							   entries[i].advice_string);
+	}
+	LWLockRelease(&pgsa_state->lock);
+}
+
+/*
+ * Create stashes in shared memory from the parsed stash hash table.
+ */
+static void
+pgsa_restore_stashes(pgsa_saved_stash_table_hash *saved_stashes)
+{
+	pgsa_saved_stash_table_iterator iter;
+	pgsa_saved_stash *s;
+
+	LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE);
+	pgsa_saved_stash_table_start_iterate(saved_stashes, &iter);
+	while ((s = pgsa_saved_stash_table_iterate(saved_stashes,
+											   &iter)) != NULL)
+	{
+		ereport(DEBUG2,
+				errmsg("restoring advice stash \"%s\"", s->name));
+		pgsa_create_stash(s->name);
+	}
+	LWLockRelease(&pgsa_state->lock);
+}
+
+/*
+ * Unescape a TSV field in place.
+ *
+ * Recognized escape sequences are \\, \t, \n, and \r.  A trailing backslash
+ * or an unrecognized escape sequence is a syntax error.
+ */
+static void
+pgsa_unescape_tsv_field(char *str, const char *filename, unsigned lineno)
+{
+	char	   *src = str;
+	char	   *dst = str;
+
+	while (*src != '\0')
+	{
+		/* Just pass through anything that's not a backslash-escape. */
+		if (likely(*src != '\\'))
+		{
+			*dst++ = *src++;
+			continue;
+		}
+
+		/* Check what sort of escape we've got. */
+		switch (src[1])
+		{
+			case '\\':
+				*dst++ = '\\';
+				break;
+			case 't':
+				*dst++ = '\t';
+				break;
+			case 'n':
+				*dst++ = '\n';
+				break;
+			case 'r':
+				*dst++ = '\r';
+				break;
+			case '\0':
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: trailing backslash",
+								filename, lineno)));
+				break;
+			default:
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("syntax error in file \"%s\" line %u: unrecognized escape \"\\%c\"",
+								filename, lineno, src[1])));
+				break;
+		}
+
+		/* We consumed the backslash and the following character. */
+		src += 2;
+	}
+	*dst = '\0';
+}
+
+/*
+ * Write an entry line for each advice entry.
+ */
+static void
+pgsa_write_entries(pgsa_writer_context *wctx)
+{
+	dshash_seq_status iter;
+	pgsa_entry *entry;
+
+	dshash_seq_init(&iter, pgsa_entry_dshash, true);
+	while ((entry = dshash_seq_next(&iter)) != NULL)
+	{
+		pgsa_stash_name *n;
+		char	   *advice_string;
+
+		if (entry->advice_string == InvalidDsaPointer)
+			continue;
+
+		n = pgsa_stash_name_table_lookup(wctx->nhash,
+										 entry->key.pgsa_stash_id);
+		if (n == NULL)
+			continue;			/* orphan entry, skip */
+
+		advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string);
+
+		resetStringInfo(&wctx->buf);
+		appendStringInfo(&wctx->buf, "entry\t%s\t%" PRId64 "\t",
+						 n->name, entry->key.queryId);
+		pgsa_append_tsv_escaped_string(&wctx->buf, advice_string);
+		appendStringInfoChar(&wctx->buf, '\n');
+		fwrite(wctx->buf.data, 1, wctx->buf.len, wctx->file);
+		if (ferror(wctx->file))
+			pgsa_write_error(wctx);
+		wctx->entries_written++;
+	}
+	dshash_seq_term(&iter);
+}
+
+/*
+ * Clean up and report a write error.  Does not return.
+ */
+static void
+pgsa_write_error(pgsa_writer_context *wctx)
+{
+	int			save_errno = errno;
+
+	FreeFile(wctx->file);
+	unlink(wctx->pathname);
+	errno = save_errno;
+	ereport(ERROR,
+			(errcode_for_file_access(),
+			 errmsg("could not write to file \"%s\": %m", wctx->pathname)));
+}
+
+/*
+ * Write a stash line for each advice stash, and populate the ID-to-name
+ * hash table for use by pgsa_write_entries.
+ */
+static void
+pgsa_write_stashes(pgsa_writer_context *wctx)
+{
+	dshash_seq_status iter;
+	pgsa_stash *stash;
+
+	dshash_seq_init(&iter, pgsa_stash_dshash, true);
+	while ((stash = dshash_seq_next(&iter)) != NULL)
+	{
+		pgsa_stash_name *n;
+		bool		found;
+
+		n = pgsa_stash_name_table_insert(wctx->nhash, stash->pgsa_stash_id,
+										 &found);
+		Assert(!found);
+		n->name = pstrdup(stash->name);
+
+		resetStringInfo(&wctx->buf);
+		appendStringInfo(&wctx->buf, "stash\t%s\n", n->name);
+		fwrite(wctx->buf.data, 1, wctx->buf.len, wctx->file);
+		if (ferror(wctx->file))
+			pgsa_write_error(wctx);
+	}
+	dshash_seq_term(&iter);
+}
diff --git a/contrib/pg_stash_advice/t/001_persist.pl b/contrib/pg_stash_advice/t/001_persist.pl
new file mode 100644
index 00000000000..d1466166602
--- /dev/null
+++ b/contrib/pg_stash_advice/t/001_persist.pl
@@ -0,0 +1,84 @@
+
+# Copyright (c) 2016-2026, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+
+$node->init;
+$node->append_conf(
+	'postgresql.conf',
+	qq{shared_preload_libraries = 'pg_plan_advice, pg_stash_advice'
+pg_stash_advice.persist = true
+pg_stash_advice.persist_interval = 0});
+$node->start;
+
+$node->safe_psql("postgres",
+		"CREATE EXTENSION pg_stash_advice;\n");
+
+# Create two stashes: one with 2 entries, one with 1 entry.
+$node->safe_psql("postgres", qq{
+	SELECT pg_create_advice_stash('stash_a');
+	SELECT pg_set_stashed_advice('stash_a', 1001, 'IndexScan(t)');
+	SELECT pg_set_stashed_advice('stash_a', 1002, E'line1\\nline2\\ttab\\\\backslash');
+	SELECT pg_create_advice_stash('stash_b');
+	SELECT pg_set_stashed_advice('stash_b', 2001, 'SeqScan(t)');
+});
+
+# Verify before restart.
+my $result = $node->safe_psql("postgres",
+	"SELECT stash_name, num_entries FROM pg_get_advice_stashes() ORDER BY stash_name");
+is($result, "stash_a|2\nstash_b|1", 'stashes present before restart');
+
+# Restart and verify the data survived.
+$node->restart;
+$node->wait_for_log("loaded 2 advice stashes and 3 entries");
+
+$result = $node->safe_psql("postgres",
+	"SELECT stash_name, num_entries FROM pg_get_advice_stashes() ORDER BY stash_name");
+is($result, "stash_a|2\nstash_b|1", 'stashes survived restart');
+
+# Verify entry contents, including the one with special characters.
+$result = $node->safe_psql("postgres",
+	"SELECT stash_name, query_id, advice_string FROM pg_get_advice_stash_contents(NULL) ORDER BY stash_name, query_id");
+is($result,
+	"stash_a|1001|IndexScan(t)\nstash_a|1002|line1\nline2\ttab\\backslash\nstash_b|2001|SeqScan(t)",
+	'entry contents survived restart with special characters intact');
+
+# Add a third stash with 0 entries.
+$node->safe_psql("postgres", qq{
+	SELECT pg_create_advice_stash('stash_c');
+});
+
+# Restart again and verify all three stashes are present.
+$node->restart;
+$node->wait_for_log("loaded 3 advice stashes and 3 entries");
+
+$result = $node->safe_psql("postgres",
+	"SELECT stash_name, num_entries FROM pg_get_advice_stashes() ORDER BY stash_name");
+is($result, "stash_a|2\nstash_b|1\nstash_c|0", 'all three stashes survived second restart');
+
+# Drop all stashes and verify the dump file is removed after restart.
+$node->safe_psql("postgres", qq{
+	SELECT pg_drop_advice_stash('stash_a');
+	SELECT pg_drop_advice_stash('stash_b');
+	SELECT pg_drop_advice_stash('stash_c');
+});
+
+$node->restart;
+
+$result = $node->safe_psql("postgres",
+	"SELECT count(*) FROM pg_get_advice_stashes()");
+is($result, "0", 'no stashes after dropping all and restarting');
+
+ok(!-f $node->data_dir . '/pg_stash_advice.tsv',
+	'dump file removed after all stashes dropped');
+
+$node->stop;
+
+done_testing();
diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
index ec60552a447..810787fe814 100644
--- a/doc/src/sgml/pgstashadvice.sgml
+++ b/doc/src/sgml/pgstashadvice.sgml
@@ -15,10 +15,12 @@
   <link linkend="guc-compute-query-id">query identifiers</link> to plan advice
   strings. Whenever a session is asked to plan a query whose query ID appears
   in the relevant advice stash, the plan advice string is automatically applied
-  to guide planning. Note that advice stashes exist purely in memory. This
-  means both that it is important to be mindful of memory consumption when
-  deciding how much plan advice to stash, and also that advice stashes must
-  be recreated and repopulated whenever the server is restarted.
+  to guide planning. Note that advice stashes are stored in dynamically
+  allocated shared memory. This means both that it is important to be mindful
+  of memory consumption when deciding how much plan advice to stash.
+  Optionally, advice stashes and their contents can automatically be persisted
+  to disk and reloaded from disk; see
+  <literal>pg_stash_advice.persist</literal>, below.
  </para>
 
  <para>
@@ -175,6 +177,28 @@
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term>
+     <function>pg_start_stash_advice_worker() returns void</function>
+     <indexterm>
+      <primary>pg_start_stash_advice_worker</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Starts the background worker, so that advice stash contents can be
+      automatically persisted to disk.  If this module is included in
+      <xref linkend="guc-shared-preload-libraries"/> at startup time with
+      <literal>pg_stash_advice.persist = true</literal>, the worker will be
+      started automatically. When started manually, the worker will not load
+      anything from disk, but it will still persist data to disk. You can then
+      configure the server to start the worker automatically after the next
+      restart, preserving any stashed advice you add now.
+     </para>
+    </listitem>
+   </varlistentry>
+
   </variablelist>
 
  </sect2>
@@ -184,6 +208,44 @@
 
   <variablelist>
 
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.persist</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.persist</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Controls whether the advice stashes and stash entries should be
+      persisted to disk. This is on by default. If any stashes are persisted,
+      a file named <literal>pg_stash_advice.tsv</literal> will be created in
+      the data directory. Stashes are loaded and saved using a background
+      worker process.  This parameter can only be set at server start.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <varname>pg_stash_advice.persist_interval</varname> (<type>integer</type>)
+     <indexterm>
+      <primary><varname>pg_stash_advice.persist_interval</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      Specifies the interval, in seconds, between checks for changes that
+      need to be written to <literal>pg_stash_advice.tsv</literal>. If set to
+      zero, changes are only written when the server shuts down. The default
+      value is <literal>30</literal>. This parameter can only be set in the
+      <filename>postgresql.conf</filename> file or on the server command line.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term>
      <varname>pg_stash_advice.stash_name</varname> (<type>string</type>)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 7f6f79875ed..c96b919d54d 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4060,10 +4060,14 @@ pgpa_trove_slice
 pgpa_unrolled_join
 pgsa_entry
 pgsa_entry_key
+pgsa_saved_entry
+pgsa_saved_stash
+pgsa_saved_stash_table_hash
 pgsa_shared_state
 pgsa_stash
 pgsa_stash_count
 pgsa_stash_name
+pgsa_writer_context
 pgsocket
 pgsql_thing_t
 pgssEntry
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-03 17:13  Robert Haas <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-03 17:13 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Apr 2, 2026 at 10:08 PM Robert Haas <[email protected]> wrote:
> I'm not sure, either, and I agree that we have a problem. I'll give it
> some more thought tomorrow.

OK, here are my thoughts.

I don't believe it's viable to change pg_plan_advice in such a way
that it won't run into trouble in cases like this.  Somebody could
argue that the choice of INDEX_SCAN(table_name index_name) was bad
design, and that I should have done something like
INDEX_SCAN(table_name indexed_columns) instead, and that might be
true. There might also be an argument that we should have both things
with different spellings, and that might also very well be true. But
we don't really know that changing that design decision would fully
stabilize test_plan_advice. The regression tests can do anything they
like, as long as they reliably pass. It now seems optimistic to me to
suppose that an index with a different name is the only current or
future issue we'll ever have. I mean, if the table were small enough
not to care about whether an index scan or a sequential scan is used,
you could concurrently drop the one and only index altogether, and
what's test_plan_advice supposed to do about that?

So, I argue that there are three possible categories of solutions
here: (1) don't let the problem cases happen in the first place, (2)
detect that a problem has happened, or (3) give up on
test_plan_advice.

In category (1), the simplest idea would be (1a) to run the tests
serially. That would probably involve running them much less often,
like in one of the CI builds but not all of them or something like
that. Another idea that I had is to (1b) try to take stronger locks on
the relations involved to prevent concurrent DDL on them, like a
ShareUpdateExclusiveLock, or (1c) some kind of bespoke interlock
specific to test_plan_advice. I think that might cause random breakage
of other types, though. Another idea in this category is to try to
make the main regression tests "pg_plan_advice clean". I know Tom
already expressed opposition to that idea, but here me out: we could
(1d) have a separate test suite that still does stuff like this, so we
don't lose test coverage, and move some stuff there. Or, instead of
completely separating it, we could (1e) have two schedule files, one
of which includes all the tests and a second of which includes only
the tests that are test_plan_advice-clean. Although my theory that the
main regression tests couldn't have multiple different sessions
simultaneously doing DDL on the same objects has been proven wrong,
I'd still be willing to bet that it's a minority position. Of course,
as Tom pointed out, there could be a "long tail" of failures here, but
maybe we could create some throwaway infrastructure to help figure
that out. For example if we're mostly worried about tables, we could
have each backend accumulate a list of table OIDs that it touched and
spit that out into the log file when it exits. That wouldn't be
committable code, but it would be enough to let us run the regression
tests with that once and see what overlaps exist. I bet there's very
low risk of newly-added tests adding more such cases: the ones that we
have are probably ancient. Of course, maybe I'm wrong about that, too,
but it's a theory that we can discuss.

In category (2), what if, (2a) whenever we see advice feedback that
we'd otherwise print, we try replanning the query a THIRD time without
any supplied advice? If we generate different advice than we did the
first time we planned it, then we know for sure that something is
unstable, and we can decide not to complain about whatever went wrong.
This isn't completely guaranteed to work, though: what if concurrent
DDL changes something between planning cycle 1 and planning cycle 2
and then changes it back before planning cycle 3? But maybe it would
be acceptable to make a rule that the main regression test case
shouldn't do that, and adjust cases that currently do to work
otherwise. If we're not willing to make any rules at all to prevent
the main regression test suite from sabotaging test_plan_advice, then
it's probably doomed. And, I think there's a reasonable argument that
insisting that the main regression test suite absolutely has to change
the definition of an object in a way that test_plan_advice will care
about and then change it back to exactly the initial state while in a
concurrent session some other backend is running queries against that
object is tantamount to legislating deliberate sabotage. But that
said, this proposal has some other imperfections as well. In
particular, a bug that caused the third planning cycle to always
produce different results than the first would hide all future
problems that test_plan_advice might have caught, which is pretty sad.
Another variant of the same basic idea is to (2b) just detect when
we've seen any shared invalidations between the start of the first
planning cycle and the end of the second, and go "never mind, don't
complain even if we saw a problem". The problem with this idea is
that, as in the previous proposal, it might make the tests too
insensitive to real issues. But I wonder if this might be fixable.
Maybe we could (2c) make test_plan_advice take planner_hook and wrap a
loop around the problem: it just keeps replanning the query via
standard_planner (which would eventually reach
test_plan_advice_advisor) until no sinval messages are absorbed
between the start and end of planner, which I think we could detect
using SharedInvalidMessageCounter, or until some retry limit is
exhausted and we error out. I'd need to try this and see how well it
works out in practice, and how often the retry is actually hit, but it
seems like it might be somewhat viable.

In category (3), the most blunt option is obviously just (3a) throw
test_plan_advice away, which I think is probably dooming
pg_plan_advice to getting silently broken in the future. I don't
really have any other ideas in this category except for (1a) already
mentioned, which is sort of a hybrid solution.

My current thought is to do some research into (1e) and (2c).
Specifically, for (1e), I want to try to figure out if this is the
only case of this type or if there are lots of others, since that
seems likely to have a pretty large bearing on what is realistic here.
And for (2c), I think I just want to try it out and see if it seems at
all feasible. Probably obviously, this is not going to happen before
next week, but I hope that the frequency of buildfarm failures is now
low enough that this isn't a critical issue. If that's wrong, let me
know, but from my point of view, even if we eventually chose (3a),
having a good a sense as possible of what the potential failure modes
are here would help to design the next solution, and AFAIK this is the
first failure we've seen since the DO_NOT_SCAN stuff went in.

(In fact, I had a little bit of trouble finding this in the BF results
even knowing it was there: filtering by test_plan_advice failures
doesn't find anything recent. sifaka's failure shows up as
TestModulesCheck-en_US.UTF-8, but frustratingly, the names for the
stage logs don't seem to quite match the name of what failed. There is
testmodules-install-check-C and
testmodules-install-check-en_US.UTF-8, but those have "install" in the
name and are punctuated differently, so it's not instantly clear that
it's the same thing. Anyway, I do see it in there now, but what I'm
saying is that if there have been other failures that are related to
this, it's possible I have missed them due to stuff like this, so it's
helpful that you (Tom) pointed this one out.)

Tom, would welcome your thoughts, if you have any, and anyone else's
thoughts as well. If none, I'll proceed as described above and update
when I know more.

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-03 18:20  Tom Lane <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Tom Lane @ 2026-04-03 18:20 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

Robert Haas <[email protected]> writes:
> (In fact, I had a little bit of trouble finding this in the BF results
> even knowing it was there: filtering by test_plan_advice failures
> doesn't find anything recent. sifaka's failure shows up as
> TestModulesCheck-en_US.UTF-8, but frustratingly, the names for the
> stage logs don't seem to quite match the name of what failed. There is
> testmodules-install-check-C and
> testmodules-install-check-en_US.UTF-8, but those have "install" in the
> name and are punctuated differently, so it's not instantly clear that
> it's the same thing. Anyway, I do see it in there now, but what I'm
> saying is that if there have been other failures that are related to
> this, it's possible I have missed them due to stuff like this, so it's
> helpful that you (Tom) pointed this one out.)

I grepped the buildfarm database for 'supplied plan advice' and got
no other hits since 6455e55b0 went in.  That's not a huge sample
size of course, but probably several hundred runs so far.  If there's
another message wording I should check for, let me know.

> Tom, would welcome your thoughts, if you have any, and anyone else's
> thoughts as well. If none, I'll proceed as described above and update
> when I know more.

I don't like anything in category 1 except (1a) run the test scripts
serially for test_plan_advice.  As I said before, I am strongly
against allowing test_plan_advice to constrain what our tests do.

Another idea in category 2, which I think is a bit different from
any option you listed, is to repeat the "plan without advice, then
again with advice, see if it matches" process up to maybe 5-ish times
before declaring failure.  If it works any one time, then write off
the previous failures as being induced by concurrent activity.
Unlike what you mentioned, this isn't dependent on sinval checks,
which I think are next door to useless in the context of the
regression tests: there's a constant storm of sinval activity going
on, to the point where you might as well figure "check for sinval
arrival" is constant "true".

However, eyeing the calendar, I think the only options that are likely
to be stabilizable before feature freeze are (1a) run the test scripts
serially for test_plan_advice or (3a) throw test_plan_advice away.
I know you don't want to do (3a) and I understand why not.  How much
will (1a) slow things down?

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-04 00:14  Robert Haas <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-04 00:14 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Fri, Apr 3, 2026 at 2:20 PM Tom Lane <[email protected]> wrote:
> However, eyeing the calendar, I think the only options that are likely
> to be stabilizable before feature freeze are (1a) run the test scripts
> serially for test_plan_advice or (3a) throw test_plan_advice away.
> I know you don't want to do (3a) and I understand why not.  How much
> will (1a) slow things down?

I don't know. For me, the speed of the regression tests is rarely a
bottleneck, and they run on my machine in about 12 seconds. But on
slow buildfarm machines, I'm guessing it's going to extend the runtime
significantly. But I also feel like if we've only seen one buildfarm
failure since the last round of stabilization, it might not be a
catastrophe if nothing further is done before feature freeze. In fact,
I think it might be *good*. Given the apparently-low failure rate that
we now have, it feels to me like we might want to run like this for a
month or even or two or three to get a clearer feeling for whether the
failure you saw is the only one or whether, perhaps, there are others.
Or even just how often this one happens. I mean, I'm also not that
opposed to having it made serial now if you really think that's
better. But what concerns me is I feel like we might inconvenience a
lot of people who really care about the tests running fast while at
the same time eliminating our ability to gather any more information
about the problem.

I mean, there is possibly an argument that we don't really need to
gather any more information about the problem; it does seem like we
understand what is going on here, and if we had a great, simple fix I
would probably just apply it and be done with it. But I also don't
quite understand why you're in such a rush. If we still feel like
running the tests serially is the best solution in a month, can't we
just do it then?

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-04 03:14  Tom Lane <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 2 replies; 133+ messages in thread

From: Tom Lane @ 2026-04-04 03:14 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

Robert Haas <[email protected]> writes:
> ... But I also feel like if we've only seen one buildfarm
> failure since the last round of stabilization, it might not be a
> catastrophe if nothing further is done before feature freeze. In fact,
> I think it might be *good*. Given the apparently-low failure rate that
> we now have, it feels to me like we might want to run like this for a
> month or even or two or three to get a clearer feeling for whether the
> failure you saw is the only one or whether, perhaps, there are others.
> Or even just how often this one happens.

Reasonable point.

> I mean, there is possibly an argument that we don't really need to
> gather any more information about the problem; it does seem like we
> understand what is going on here, and if we had a great, simple fix I
> would probably just apply it and be done with it. But I also don't
> quite understand why you're in such a rush. If we still feel like
> running the tests serially is the best solution in a month, can't we
> just do it then?

The terms that I'm thinking in are "how much redesign will we accept
post-feature-freeze, in either pg_plan_advice or test_plan_advice,
before choosing to revert those modules entirely for v19?".  I think
that running those tests serially is a sufficiently low-risk option
that it'd be okay to put it in post-freeze, even very long after.
I'm not sure that any of the other group-1 or group-2 options you
suggested would be okay post-freeze.  (Of course, ultimately that'd
be the RMT's decision not mine.)

I believe that we probably will need to do something in this
area before v19 release.  If we're willing to commit to it being
"run the tests serially", then sure we can wait awhile before
actually doing that.  Maybe we'll even think of a better idea
... but what we can do about this post-freeze seems pretty
constrained to me.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-04 08:11  Lukas Fittl <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Lukas Fittl @ 2026-04-04 08:11 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Thu, Apr 2, 2026 at 7:15 PM Robert Haas <[email protected]> wrote:
>
> On Thu, Apr 2, 2026 at 12:15 PM Robert Haas <[email protected]> wrote:
> > So here's v24, also dropping pg_collect_advice.
>
> That version didn't actually pass CI. Here's v25.

I've reviewed the pg_stash_advice code and documentation, and I think
this is overall sound.  As I mentioned previously, I think its a very
important addition to make pg_plan_advice work for practical problems
end users encounter.

To me this looks good to go, with three minor notes below. For context
I've spent a few hours today going through the code manually, and
doing testing.

And thank you for the detailed notes in your earlier email, and
reworking this. Regarding authorship, I'm happy to be listed as
co-author on the persistence part if you want to keep that in the
commit message. Overall I'm also willing to put in work during the
remaining cycle to test/review/address issues in pg_plan_advice or
pg_stash_advice, so we can hopefully sort out the other items being
discussed.

For 0001:

> diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml
> new file mode 100644
> index 00000000000..ec60552a447
> --- /dev/null
> +++ b/doc/src/sgml/pgstashadvice.sgml
> @@ -0,0 +1,216 @@
> ...
> +   <varlistentry>
> +    <term>
> +     <function>pg_create_advice_stash(stash_name text) returns void</function>
> +     <indexterm>
> +      <primary>pg_create_advice_stash</primary>
> +     </indexterm>
> +    </term>
> +
> +    <listitem>
> +     <para>
> +      Creates a new, empty advice stash with the given name.
> +     </para>
> +    </listitem>
> +   </varlistentry>
> +

I think we should document the restrictions on advice names here (i.e.
they must be alphanumeric or contain an underscore, not start with a
digit, and maximum NAMEDATLEN).

For 0002:

> diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c
> index 15e7adf849b..1858c6a135a 100644
> --- a/contrib/pg_stash_advice/pg_stash_advice.c
> +++ b/contrib/pg_stash_advice/pg_stash_advice.c
> ...
> @@ -464,6 +522,43 @@ pgsa_drop_stash(char *stash_name)
>         }
>     }
>     dshash_seq_term(&iterator);
> +
> +    /* Bump change count. */
> +    pg_atomic_add_fetch_u64(&pgsa_state->change_count, 1);
> +}
> +
> +/*
> + * Remove all stashes and entries from shared memory.
> + *
> + * This is intended to be called before reloading from a dump file, so that
> + * a failed previous attempt doesn't leave stale data behind.
> + */
> +void
> +pgsa_reset_all_stashes(void)
> +{

I think this might be good to expose on the SQL level as well - in
case someone accidentally created a lot of stashes it could be tedious
to remove them all, e.g. if they wanted to clear all the memory after
an experiment.

> diff --git a/contrib/pg_stash_advice/stashpersist.c b/contrib/pg_stash_advice/stashpersist.c
> new file mode 100644
> index 00000000000..da96ee0d803
> --- /dev/null
> +++ b/contrib/pg_stash_advice/stashpersist.c
>...
> +            /* Parse the query ID. */
> +            errno = 0;
> +            queryId = strtoll(queryid_str, &endptr, 10);
> +            if (*endptr != '\0' || errno != 0 || queryid_str == endptr)
> +                ereport(ERROR,
> +                        (errcode(ERRCODE_DATA_CORRUPTED),
> +                         errmsg("syntax error in file \"%s\" line %u: invalid query ID \"%s\"",
> +                                PGSA_DUMP_FILE, lineno, queryid_str)));
> +

It might be worth adding a queryId == 0 check here, since we won't
check it later, and its helpful to avoid unpredictable behavior just
in case someone decided to mess with the file manually.

Thanks,
Lukas

-- 
Lukas Fittl





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-04 09:34  Andrei Lepikhov <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Andrei Lepikhov @ 2026-04-04 09:34 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; Robert Haas <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On 4/4/26 05:14, Tom Lane wrote:
> Robert Haas <[email protected]> writes:
> The terms that I'm thinking in are "how much redesign will we accept
> post-feature-freeze, in either pg_plan_advice or test_plan_advice,
> before choosing to revert those modules entirely for v19?".  I think
> that running those tests serially is a sufficiently low-risk option
> that it'd be okay to put it in post-freeze, even very long after.
> I'm not sure that any of the other group-1 or group-2 options you
> suggested would be okay post-freeze.  (Of course, ultimately that'd
> be the RMT's decision not mine.)
> 
> I believe that we probably will need to do something in this
> area before v19 release.  If we're willing to commit to it being
> "run the tests serially", then sure we can wait awhile before
> actually doing that.  Maybe we'll even think of a better idea
> ... but what we can do about this post-freeze seems pretty
> constrained to me.

As you work on the code, please keep the pg_plan_advice issue [1] in 
mind. I came across it while designing the optimisation in [2]. Even if 
[2] is not added to the Postgres core, this still looks like a valid 
query plan and may be proposed by an extension. So, the hinting module 
should avoid conflicts with other extensions, just as pg_hint_plan does.

[1] pg_plan_advice fails when NestLoop outer side is Sort over FunctionScan
https://www.postgresql.org/message-id/[email protected]
[2] Try a presorted outer path when referenced by an ORDER BY prefix
https://www.postgresql.org/message-id/19a9265c-c441-4a43-bc0d-dac533438da0%40gmail.com

-- 
regards, Andrei Lepikhov,
pgEdge





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-04 18:42  Robert Haas <[email protected]>
  parent: Andrei Lepikhov <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-04 18:42 UTC (permalink / raw)
  To: Andrei Lepikhov <[email protected]>; +Cc: Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sat, Apr 4, 2026 at 5:34 AM Andrei Lepikhov <[email protected]> wrote:
> As you work on the code, please keep the pg_plan_advice issue [1] in
> mind. I came across it while designing the optimisation in [2]. Even if
> [2] is not added to the Postgres core, this still looks like a valid
> query plan and may be proposed by an extension. So, the hinting module
> should avoid conflicts with other extensions, just as pg_hint_plan does.
>
> [1] pg_plan_advice fails when NestLoop outer side is Sort over FunctionScan
> https://www.postgresql.org/message-id/[email protected]
> [2] Try a presorted outer path when referenced by an ORDER BY prefix
> https://www.postgresql.org/message-id/19a9265c-c441-4a43-bc0d-dac533438da0%40gmail.com

I'll take a look at that issue when I have a free moment. We certainly
cannot promise in general that pg_plan_advice will be able to make
sense of plans that PostgreSQL's own planner does not produce; that
would require magical code. But there might be something that can be
done to ameliorate this particular instance.

By the way, I'm really glad you hit that error. That particular error
check is there precisely to find plans that pg_plan_advice isn't able
to understand, and it sounds like it is doing its job as intended.
Having problems isn't great, but knowing that you have problems is a
lot better than still having them but not knowing about it.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-04 21:02  Andrei Lepikhov <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 2 replies; 133+ messages in thread

From: Andrei Lepikhov @ 2026-04-04 21:02 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On 4/4/26 20:42, Robert Haas wrote:
> On Sat, Apr 4, 2026 at 5:34 AM Andrei Lepikhov <[email protected]> wrote:
> By the way, I'm really glad you hit that error. That particular error
> check is there precisely to find plans that pg_plan_advice isn't able
> to understand, and it sounds like it is doing its job as intended.
> Having problems isn't great, but knowing that you have problems is a
> lot better than still having them but not knowing about it.
That’s exactly what concerns me. I see it as a potential design flaw if 
the extension has to make assumptions about possible plan configurations.
I’m not sure how it works in detail, of course. However, when I designed 
Postgres replanning in the past, and made similar core changes to what 
you’ve done for pg_plan_advice, this kind of problem couldn’t have 
happened. So, I think it’s worth questioning the current approach and 
looking for other options.

-- 
regards, Andrei Lepikhov,
pgEdge





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-04 22:52  Robert Haas <[email protected]>
  parent: Andrei Lepikhov <[email protected]>
  1 sibling, 2 replies; 133+ messages in thread

From: Robert Haas @ 2026-04-04 22:52 UTC (permalink / raw)
  To: Andrei Lepikhov <[email protected]>; +Cc: Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sat, Apr 4, 2026 at 5:02 PM Andrei Lepikhov <[email protected]> wrote:
> That’s exactly what concerns me. I see it as a potential design flaw if
> the extension has to make assumptions about possible plan configurations.
> I’m not sure how it works in detail, of course. However, when I designed
> Postgres replanning in the past, and made similar core changes to what
> you’ve done for pg_plan_advice, this kind of problem couldn’t have
> happened. So, I think it’s worth questioning the current approach and
> looking for other options.

I mean, any plan stability feature is intrinsically tied to a
particular planner. Nobody thinks you can use Aurora Postgres's Query
Plan Management feature with MySQL or DB2 or Oracle. Those products
obviously have to have their own features for plan stability. The same
is true here. There's more overlap because you're creating the plan
out of the same basic building blocks rather than an entirely
different set of things, but if you assemble them in a way that
PostgreSQL doesn't, then some things may not work. pg_plan_advice is
one of those things; the executor is another. Of course, I don't think
anybody here is keen to break stuff for no good reason, which is why I
will take a look at the report you posted. But fundamentally, it's the
same issue. If somebody uses a plugin that replaces large parts of the
plan with a CustomScan, pg_plan_advice isn't going to work with that,
either: how could it possibly? Maybe there could be some way to make
pg_plan_advice pluggable so that if extensions fiddle with the planner
they can also do matching fiddling with pg_plan_advice if they're so
inclined, but having it "just work" would require magic.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-05 07:57  Andrei Lepikhov <[email protected]>
  parent: Robert Haas <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Andrei Lepikhov @ 2026-04-05 07:57 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On 5/4/26 00:52, Robert Haas wrote:
> On Sat, Apr 4, 2026 at 5:02 PM Andrei Lepikhov <[email protected]> wrote:
>> That’s exactly what concerns me. I see it as a potential design flaw if
>> the extension has to make assumptions about possible plan configurations.
>> I’m not sure how it works in detail, of course. However, when I designed
>> Postgres replanning in the past, and made similar core changes to what
>> you’ve done for pg_plan_advice, this kind of problem couldn’t have
>> happened. So, I think it’s worth questioning the current approach and
>> looking for other options.
> 
> I mean, any plan stability feature is intrinsically tied to a
> particular planner. Nobody thinks you can use Aurora Postgres's Query
> Plan Management feature with MySQL or DB2 or Oracle. Those products

I don’t expect any Postgres extension to work in DB2.

These optimisations are simple. Here, I provided the optimiser with one 
extra path that it skipped itself just to reduce computational overhead 
- nice in the general case, but not ok in analytics. This extension of 
planning scope allowed the optimiser to build JOIN over the Sort 
operator, which didn’t change the main logic at all. I followed the 
usual cost-based model and used add_path.

Another optimisation improves Memoize so it can run on top of SubPlan 
when the cost model predicts many repeated parameter values. One more 
extension uses MergeJoin estimation on the required values of its inputs 
to determine how many tuples are needed from each input, which adds 
kinda 'soft' LIMIT emerged from the plan structure ... The Append node 
serves as the backbone of any partitioning or sharding setup, but 
contributors often overlook it, and we use multiple extra optimisations 
here too.

There’s a lot to say about branched out-of-core optimisations 
infrastructure, but it’s clear that supporting analytical workloads 
means adding extra features. Developers usually stick to standard 
Postgres practices, cost model and routines providing the planner with 
alternatives without forcing any 'magical' paths. So, they expect 
built-in extensions not to interfere with their code by design.

Looking back at the pg_plan_advice development cycle, I don’t see many 
discussions about the design. It seems unusual given how complex the 
planner's structure is. It makes sense to follow the typical way and let 
it serve out of the contrib for some time and see if it works well.

Introducing such a module into the core would effectively cancel 
alternative solutions, as seen with PGSS. Therefore, it is important to 
ensure the code is well-designed before proceeding. Do you agree?

-- 
regards, Andrei Lepikhov,
pgEdge





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-05 08:00  Alexander Lakhin <[email protected]>
  parent: Robert Haas <[email protected]>
  1 sibling, 2 replies; 133+ messages in thread

From: Alexander Lakhin @ 2026-04-05 08:00 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; Tom Lane <[email protected]>; +Cc: Lukas Fittl <[email protected]>; Andrei Lepikhov <[email protected]>; PostgreSQL Hackers <[email protected]>

Hello Robert,

I and SQLsmith have discovered one more anomaly (reproduced starting from
e0e4c132e):
load 'test_plan_advice';
select object_type from
  (select object_type from information_schema.element_types limit 1),
  lateral
  (select sum(1) over (partition by a) from generate_series(1, 2) g(a) where false);

triggers an internal error:
ERROR:  XX000: no rtoffset for plan unnamed_subquery
LOCATION:  pgpa_plan_walker, pgpa_walker.c:110

Could you please have a look?

Best regards,
Alexander





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-05 12:00  Alexander Lakhin <[email protected]>
  parent: Alexander Lakhin <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Alexander Lakhin @ 2026-04-05 12:00 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; Tom Lane <[email protected]>; +Cc: Lukas Fittl <[email protected]>; Andrei Lepikhov <[email protected]>; PostgreSQL Hackers <[email protected]>

05.04.2026 11:00, Alexander Lakhin wrote:
> I and SQLsmith have discovered one more anomaly (reproduced starting from
> e0e4c132e):
> load 'test_plan_advice';
> select object_type from
>  (select object_type from information_schema.element_types limit 1),
>  lateral
>  (select sum(1) over (partition by a) from generate_series(1, 2) g(a) where false);
>
> triggers an internal error:
> ERROR:  XX000: no rtoffset for plan unnamed_subquery
> LOCATION:  pgpa_plan_walker, pgpa_walker.c:110

And another error, which might be interesting to you:
CREATE EXTENSION tsm_system_time;
CREATE TABLE t(i int);
SELECT 1 FROM (SELECT i FROM t TABLESAMPLE system_time (1000)), LATERAL (SELECT i LIMIT 1);

ERROR:  XX000: plan node has no RTIs: 378
LOCATION:  pgpa_build_scan, pgpa_scan.c:200

Best regards,
Alexander





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-06 12:47  Robert Haas <[email protected]>
  parent: Andrei Lepikhov <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-06 12:47 UTC (permalink / raw)
  To: Andrei Lepikhov <[email protected]>; +Cc: Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sun, Apr 5, 2026 at 3:57 AM Andrei Lepikhov <[email protected]> wrote:
> Looking back at the pg_plan_advice development cycle, I don’t see many
> discussions about the design. It seems unusual given how complex the
> planner's structure is. It makes sense to follow the typical way and let
> it serve out of the contrib for some time and see if it works well.
>
> Introducing such a module into the core would effectively cancel
> alternative solutions, as seen with PGSS. Therefore, it is important to
> ensure the code is well-designed before proceeding. Do you agree?

I don't know how anyone could disagree with the idea that PostgreSQL
code should be well-designed, but that doesn't mean that I agree that
your particular design criticism is fair, and I definitely don't.

As for the amount of design discussion on the mailing list, I was
disappointed in that, too. In addition to posting to the list, I
privately asked numerous people to help review and test. Some did, but
on the whole, I was expecting a more vigorous debate and a lot of
people telling me what an idiot I am. Instead, the most common
feedback I got was some form of "can you ship it right now, please?".
That probably has less to do with the design being good (although I
believe that it is) or my code being good (although I hope that it is)
than with people just really wanting PostgreSQL to have something of
this sort. So I am somewhat afraid that this will turn out to have
more problems than anyone has noticed so far, and maybe for reasons
that will feel dumb in hindsight. But on March 12th, I asked myself
whether more people were going to be unhappy if I committed
pg_plan_advice this release cycle or if I didn't, and my educated
guess was the latter, so I committed it. If that turns out to have
been the wrong call, then I apologize to the whole community in
advance.

But I do not apologize for the fact that pg_plan_advice tries to
interpret plan trees -- which I personally think is one of the best
design decisions I have ever made while hacking on PostgreSQL -- or
that it can't interpret the variant ones that your extension produces.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-06 13:22  Andrei Lepikhov <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Andrei Lepikhov @ 2026-04-06 13:22 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On 06/04/2026 14:47, Robert Haas wrote:
> On Sun, Apr 5, 2026 at 3:57 AM Andrei Lepikhov <[email protected]> wrote:
>> Looking back at the pg_plan_advice development cycle, I don’t see many
>> discussions about the design. It seems unusual given how complex the
>> planner's structure is. It makes sense to follow the typical way and let
>> it serve out of the contrib for some time and see if it works well.
> But I do not apologize for the fact that pg_plan_advice tries to
> interpret plan trees -- which I personally think is one of the best
> design decisions I have ever made while hacking on PostgreSQL -- or
> that it can't interpret the variant ones that your extension produces.

I challenge solely the design of the extension, not interested in holy 
wars on the hinting approach.
Postgres modules that use hooks are second-class citizens because the 
core hooks were never designed to let an extension module be as 
effective as the core code. It's probably OK, considering safety and 
maintainability concerns.
But this extension effectively makes alternative modules third-class 
citizens (not sure such a term exists in English) - people prioritise 
contrib modules over any others. And they definitely will use this one. 
So, I envision complaints about conflicting extensions in the near 
future - think about Citus or TimescaleDB optimisations, for example.
It would be better to introduce such a code at the beginning of the 
development cycle, not right before the code freeze. At least we would 
discuss its design without rushing.

-- 
regards, Andrei Lepikhov,
pgEdge





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-06 13:56  Andres Freund <[email protected]>
  parent: Andrei Lepikhov <[email protected]>
  1 sibling, 2 replies; 133+ messages in thread

From: Andres Freund @ 2026-04-06 13:56 UTC (permalink / raw)
  To: Andrei Lepikhov <[email protected]>; +Cc: Robert Haas <[email protected]>; Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

Hi,

On 2026-04-04 23:02:37 +0200, Andrei Lepikhov wrote:
> On 4/4/26 20:42, Robert Haas wrote:
> > On Sat, Apr 4, 2026 at 5:34 AM Andrei Lepikhov <[email protected]> wrote:
> > By the way, I'm really glad you hit that error. That particular error
> > check is there precisely to find plans that pg_plan_advice isn't able
> > to understand, and it sounds like it is doing its job as intended.
> > Having problems isn't great, but knowing that you have problems is a
> > lot better than still having them but not knowing about it.
> That’s exactly what concerns me. I see it as a potential design flaw if the
> extension has to make assumptions about possible plan configurations.
> I’m not sure how it works in detail, of course. However, when I designed
> Postgres replanning in the past, and made similar core changes to what
> you’ve done for pg_plan_advice, this kind of problem couldn’t have happened.
> So, I think it’s worth questioning the current approach and looking for
> other options.

You're making sweeping high-level demands, implying they're easy ("when I
designed ... this kind of problem couldn’t have happened"), without any
concrete technical suggestions for how to actually achieve that.  In very
strong language.  Your high level demand, that somehow plan shape influencing
code should just work regardless of what crazy thing extensions have done
seems ... not entirely realistic, to put it very kindly.

I suggest you rethink your approach of engaging with others.

Greetings,

Andres Freund





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-06 14:01  Robert Haas <[email protected]>
  parent: Andrei Lepikhov <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-06 14:01 UTC (permalink / raw)
  To: Andrei Lepikhov <[email protected]>; +Cc: Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Apr 6, 2026 at 9:22 AM Andrei Lepikhov <[email protected]> wrote:
> So, I envision complaints about conflicting extensions in the near
> future - think about Citus or TimescaleDB optimisations, for example.

Definitely possible.

> It would be better to introduce such a code at the beginning of the
> development cycle, not right before the code freeze. At least we would
> discuss its design without rushing.

Yes, the timing is not ideal. However, I posted the patch on October
30th and committed the main patch on March 12th. I think that's a
reasonable length of time to wait for people to provide feedback.
During that time, the only person who provided information on how this
will interact with out-of-core extensions was Lukas Fittl, who came to
the conclusion that the pgs_mask infrastructure will be reusable by
pg_hint_plan and will result in that module being simpler and
involving less code duplication. Other extension authors could have
provided feedback during that time as well, but none did, even after I
posted to my blog to try to raise the visibility of this project. As
far as I can tell, most extension developers don't pay much attention
to core development until after we ship a beta. Had I waited until
July to commit, I think there's a chance that it would have simply
resulted in me getting whatever feedback I'm going to get next summer
rather than this summer. At least this way, the issues will hopefully
be fresh in my mind when the feedback arrives.

Of course, you also seem to be assuming that whatever feedback I get
will be negative, and it may well be. But, there is also some tiny
possibility that I have done a good job and that people will like it.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-06 14:14  Peter Geoghegan <[email protected]>
  parent: Andres Freund <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Peter Geoghegan @ 2026-04-06 14:14 UTC (permalink / raw)
  To: Andres Freund <[email protected]>; +Cc: Andrei Lepikhov <[email protected]>; Robert Haas <[email protected]>; Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Apr 6, 2026 at 9:56 AM Andres Freund <[email protected]> wrote:
> You're making sweeping high-level demands, implying they're easy ("when I
> designed ... this kind of problem couldn’t have happened"), without any
> concrete technical suggestions for how to actually achieve that.  In very
> strong language.  Your high level demand, that somehow plan shape influencing
> code should just work regardless of what crazy thing extensions have done
> seems ... not entirely realistic, to put it very kindly.
>
> I suggest you rethink your approach of engaging with others.

+1

-- 
Peter Geoghegan





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-06 15:11  Andrei Lepikhov <[email protected]>
  parent: Andres Freund <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Andrei Lepikhov @ 2026-04-06 15:11 UTC (permalink / raw)
  To: Andres Freund <[email protected]>; +Cc: Robert Haas <[email protected]>; Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On 06/04/2026 15:56, Andres Freund wrote:
> You're making sweeping high-level demands, implying they're easy ("when I
> designed ... this kind of problem couldn’t have happened"), without any
> concrete technical suggestions for how to actually achieve that.  In very
> strong language.  Your high level demand, that somehow plan shape influencing
> code should just work regardless of what crazy thing extensions have done
> seems ... not entirely realistic, to put it very kindly.
Sorry about that.

I haven't had much practice with English. Sometimes, things I wouldn't 
normally say in technical discussions in my native language come out 
here. As well as part of the meaning definitely lost in translation.
The actual reason was to highlight that quite closely related features 
exist in the Postgres world (not only pg_hint_plan). Even if we can’t 
expose the code of enterprise forks, it worth to discuss alternative 
design ideas.

-- 
regards, Andrei Lepikhov,
pgEdge





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-06 19:52  Robert Haas <[email protected]>
  parent: Alexander Lakhin <[email protected]>
  1 sibling, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-04-06 19:52 UTC (permalink / raw)
  To: Alexander Lakhin <[email protected]>; +Cc: Tom Lane <[email protected]>; Lukas Fittl <[email protected]>; Andrei Lepikhov <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sun, Apr 5, 2026 at 4:00 AM Alexander Lakhin <[email protected]> wrote:
> I and SQLsmith have discovered one more anomaly (reproduced starting from
> e0e4c132e):
> load 'test_plan_advice';
> select object_type from
>   (select object_type from information_schema.element_types limit 1),
>   lateral
>   (select sum(1) over (partition by a) from generate_series(1, 2) g(a) where false);
>
> triggers an internal error:
> ERROR:  XX000: no rtoffset for plan unnamed_subquery
> LOCATION:  pgpa_plan_walker, pgpa_walker.c:110
>
> Could you please have a look?

Thanks for the report. What seems to be happening here is that the
whole query is replaced by a single Result node, since the join must
be empty. But that means that unnamed_subquery doesn't make it into
the final plan tree, and then pgpa_plan_walker() is sad about not
finding it. Normally it wouldn't care, but apparently this query
involves at least one semijoin someplace that the planner considered
converting into a regular join with one side made unique, so
pgpa_plan_walker() has an entry in sj_unique_rels and then wants to
adjust that entry for the final, flattened range table, and it can't.
I'm inclined to think that the fix is just:

-            elog(ERROR, "no rtoffset for plan %s", proot->plan_name);
+            continue;

...plus a comment update, but I want to spend some time mulling over
whether that might break anything else before I go do it.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-06 20:15  Robert Haas <[email protected]>
  parent: Alexander Lakhin <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-04-06 20:15 UTC (permalink / raw)
  To: Alexander Lakhin <[email protected]>; +Cc: Tom Lane <[email protected]>; Lukas Fittl <[email protected]>; Andrei Lepikhov <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sun, Apr 5, 2026 at 8:00 AM Alexander Lakhin <[email protected]> wrote:
> And another error, which might be interesting to you:
> CREATE EXTENSION tsm_system_time;
> CREATE TABLE t(i int);
> SELECT 1 FROM (SELECT i FROM t TABLESAMPLE system_time (1000)), LATERAL (SELECT i LIMIT 1);
>
> ERROR:  XX000: plan node has no RTIs: 378
> LOCATION:  pgpa_build_scan, pgpa_scan.c:200

Thanks also for this report. The plan looks like this:

 Nested Loop  (cost=0.00..154.75 rows=2550 width=4)
   ->  Materialize  (cost=0.00..78.25 rows=2550 width=4)
         ->  Sample Scan on t  (cost=0.00..65.50 rows=2550 width=4)
               Sampling: system_time ('1000'::double precision)
   ->  Limit  (cost=0.00..0.01 rows=1 width=4)
         ->  Result  (cost=0.00..0.01 rows=1 width=4)

And it's unhappy because it's expecting the Materialize node to be the
RTI-bearing node. In a turn of events that will probably shock nobody
here, I also didn't quite realize that a Materialize node could get
inserted here. It's kind of a problem, too, because what if the sides
of the join were switched? Then we'd have a Nested Loop with an inner
Materialize node and would conclude that the strategy was
PGS_NESTLOOP_MATERIALIZE, when in reality it would be
PGS_NESTLOOP_PLAIN plus a Materialize node inserted at the scan level,
so the generated advice would be incorrect. I guess the fix is
probably to view a Materialize node on top of a Sample Scan for a
!repeatable_across_scans tsmhandler as part of the scan, which is kind
of annoying but probably doable. Not for the first time, I really wish
we stored an RTI set in every plan node, or (maybe more economically)
had some kind of enum in key plan nodes indicating why the node was
inserted. Right now, pg_plan_advice does a lot of reading the tea
leaves, which is great in that it avoids bloating Plan trees with
additional metadata, but a little scary in terms of being able to be
certain that one will get the right answer reliably.

I'll work on a fix.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-07 14:17  Robert Haas <[email protected]>
  parent: Lukas Fittl <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-04-07 14:17 UTC (permalink / raw)
  To: Lukas Fittl <[email protected]>; +Cc: Jakub Wartak <[email protected]>; Tom Lane <[email protected]>; PostgreSQL Hackers <[email protected]>

On Sat, Apr 4, 2026 at 4:12 AM Lukas Fittl <[email protected]> wrote:
> I think we should document the restrictions on advice names here (i.e.
> they must be alphanumeric or contain an underscore, not start with a
> digit, and maximum NAMEDATLEN).

I committed 0001 without this change. Please feel free to propose a
clean-up patch that adds this. I wasn't certain where the best place
to add it was, or what the wording ought to be exactly.

> > +/*
> > + * Remove all stashes and entries from shared memory.
> > + *
> > + * This is intended to be called before reloading from a dump file, so that
> > + * a failed previous attempt doesn't leave stale data behind.
> > + */
> > +void
> > +pgsa_reset_all_stashes(void)
> > +{
>
> I think this might be good to expose on the SQL level as well - in
> case someone accidentally created a lot of stashes it could be tedious
> to remove them all, e.g. if they wanted to clear all the memory after
> an experiment.



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-07 21:21  Robert Haas <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-07 21:21 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; Nathan Bossart <[email protected]>; Melanie Plageman <[email protected]>; heikki.linnakangas <[email protected]>

On Fri, Apr 3, 2026 at 11:14 PM Tom Lane <[email protected]> wrote:
> I believe that we probably will need to do something in this
> area before v19 release.  If we're willing to commit to it being
> "run the tests serially", then sure we can wait awhile before
> actually doing that.  Maybe we'll even think of a better idea
> ... but what we can do about this post-freeze seems pretty
> constrained to me.

Here's a new patch set. All of these patches are new, but I'm
continuing to increment the same version number sequence.

0001 and 0002 implement the "retry a few times" idea for avoiding
test_plan_advice failures. I argue that (a) these are reasonable
post-commit stabilization that should not be blocked by feature freeze
and (b) most people here will be happier with a solution like this
that will normally cost very little than they will be with switching
test_plan_advice to executing serially. The RMT can decide whether it
agrees. The other question here is whether it's really a good idea to
apply this now considering that we've seen only one failure so far. I
think it's probably a good idea to do something like this before
release, so that we hopefully reduce the false positive rate from the
test to something much closer to zero, but I think we've still had
only the one failure, and I'm really interested in knowing how close
the failure rate is to zero already. The RMT may have an opinion on
how long to wait before doing something like this, too.

0003 fixes the problem with tablesample scans that Alexander Lakhin
reported. The bug occurs when a tablesample handler does not set
repeatable_across_scans and the resulting Sample Scan appears below a
join. The test case provided by Alexander shows the Sample Scan on the
inner side of the join, but it's also possible to construct a case
where it occurs on the outer side of the join. This commit adds tests
for both cases.

0004 fixes an oversight in commit
6455e55b0da47255f332a96f005ba0dd1c7176c2, which failed to add a new
pg_regress test to the pg_plan_advice Makefile.

0005 fixes the other issue that Alexander Lakhin recently reported,
which manifested as ERROR:  no rtoffset for plan unnamed_subquery when
trying to generate advice. That turns out to occur when a subquery is
proven empty and that subquery contains a semijoin that could have
been implemented by making one side unique. I chose a different fix
than what I mentioned in my response to Alexander's email. There was
already code that handles the case where a SubPlanRTInfo exists and is
marked dummy, and this fix extends that handling to the case where no
SubPlanRTInfo exists at all, which seems better than treating those
two cases in separate parts of the code.

I don't think that anyone will argue that 0003-0005 are things we
can't or shouldn't fix after feature freeze, and I plan to apply those
fixes shortly after feature freeze, unless there are objections or
better ideas. I could rush them in before that, too, but I don't think
what the tree needs are more people trying to commit all at once right
now.

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com


Attachments:

  [application/octet-stream] v26-0001-pg_plan_advice-Export-feedback-related-definitio.patch (15.6K, 2-v26-0001-pg_plan_advice-Export-feedback-related-definitio.patch)
  download | inline diff:
From cd2f70b2790c94d630a6e42526510f3c8e5c0b31 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 7 Apr 2026 12:39:21 -0400
Subject: [PATCH v26 1/5] pg_plan_advice: Export feedback-related definitions.

In preparation for making test_plan_advice slightly smarter, this
commit makes a couple of things related to pg_plan_advice visible
to other extensions. The PGPA_TE_* constants are renamed to
PGPA_FB_* and moved to pg_plan_advice.h. Also, the function
pgpa_planner_feedback_warning is made non-static and marked as
PGDLLEXPORT.
---
 contrib/pg_plan_advice/pg_plan_advice.h | 29 +++++++++++
 contrib/pg_plan_advice/pgpa_planner.c   | 69 ++++++++++++-------------
 contrib/pg_plan_advice/pgpa_planner.h   |  3 ++
 contrib/pg_plan_advice/pgpa_trove.c     | 17 +++---
 contrib/pg_plan_advice/pgpa_trove.h     | 30 -----------
 5 files changed, 75 insertions(+), 73 deletions(-)

diff --git a/contrib/pg_plan_advice/pg_plan_advice.h b/contrib/pg_plan_advice/pg_plan_advice.h
index 749331b6b8a..d7847715350 100644
--- a/contrib/pg_plan_advice/pg_plan_advice.h
+++ b/contrib/pg_plan_advice/pg_plan_advice.h
@@ -15,6 +15,35 @@
 #include "commands/explain_state.h"
 #include "nodes/pathnodes.h"
 
+/*
+ * Flags used in plan advice feedback.
+ *
+ * PGPA_FB_MATCH_PARTIAL means that we found some part of the query that at
+ * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
+ * be set if we ever saw any joinrel including either "a" or "b".
+ *
+ * PGPA_FB_MATCH_FULL means that we found an exact match for the target; e.g.
+ * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
+ * exactly "a" and "b" and nothing else.
+ *
+ * PGPA_FB_INAPPLICABLE means that the advice doesn't properly apply to the
+ * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
+ * exist on foo. The fact that this bit has been set does not mean that the
+ * advice had no effect.
+ *
+ * PGPA_FB_CONFLICTING means that a conflict was detected between what this
+ * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
+ * would conflict with HASH_JOIN(a), because the former requires "a" to be the
+ * outer table while the latter requires it to be the inner table.
+ *
+ * PGPA_FB_FAILED means that the resulting plan did not conform to the advice.
+ */
+#define PGPA_FB_MATCH_PARTIAL		0x0001
+#define PGPA_FB_MATCH_FULL			0x0002
+#define PGPA_FB_INAPPLICABLE		0x0004
+#define PGPA_FB_CONFLICTING			0x0008
+#define PGPA_FB_FAILED				0x0010
+
 /* Hook for other plugins to supply advice strings */
 typedef char *(*pg_plan_advice_advisor_hook) (PlannerGlobal *glob,
 											  Query *parse,
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
index b25f62b2e87..86b37e79b57 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -159,7 +159,6 @@ static List *pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
 										  pgpa_trove_lookup_type type,
 										  pgpa_identifier *rt_identifiers,
 										  pgpa_plan_walker_context *walker);
-static void pgpa_planner_feedback_warning(List *feedback);
 
 static pgpa_planner_info *pgpa_planner_get_proot(pgpa_planner_state *pps,
 												 PlannerInfo *root);
@@ -876,19 +875,19 @@ pgpa_planner_apply_joinrel_advice(uint64 *pgs_mask_p, char *plan_name,
 	 * the set of targets exactly matched this relation, fully matched. If
 	 * there was a conflict, mark them all as conflicting.
 	 */
-	flags = PGPA_TE_MATCH_PARTIAL;
+	flags = PGPA_FB_MATCH_PARTIAL;
 	if (gather_conflict)
-		flags |= PGPA_TE_CONFLICTING;
+		flags |= PGPA_FB_CONFLICTING;
 	pgpa_trove_set_flags(pjs->rel_entries, gather_partial_match, flags);
-	flags |= PGPA_TE_MATCH_FULL;
+	flags |= PGPA_FB_MATCH_FULL;
 	pgpa_trove_set_flags(pjs->rel_entries, gather_full_match, flags);
 
 	/* Likewise for partitionwise advice. */
-	flags = PGPA_TE_MATCH_PARTIAL;
+	flags = PGPA_FB_MATCH_PARTIAL;
 	if (partitionwise_conflict)
-		flags |= PGPA_TE_CONFLICTING;
+		flags |= PGPA_FB_CONFLICTING;
 	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_partial_match, flags);
-	flags |= PGPA_TE_MATCH_FULL;
+	flags |= PGPA_FB_MATCH_FULL;
 	pgpa_trove_set_flags(pjs->rel_entries, partitionwise_full_match, flags);
 
 	/*
@@ -1082,7 +1081,7 @@ pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
 					 * This doesn't seem to be a semijoin to which SJ_UNIQUE
 					 * or SJ_NON_UNIQUE can be applied.
 					 */
-					entry->flags |= PGPA_TE_INAPPLICABLE;
+					entry->flags |= PGPA_FB_INAPPLICABLE;
 				}
 				else if (advice_unique != jt_unique)
 					sj_deny_indexes = bms_add_member(sj_deny_indexes, i);
@@ -1102,11 +1101,11 @@ pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
 		(jo_deny_indexes != NULL || jo_deny_rel_indexes != NULL))
 	{
 		pgpa_trove_set_flags(pjs->join_entries, jo_permit_indexes,
-							 PGPA_TE_CONFLICTING);
+							 PGPA_FB_CONFLICTING);
 		pgpa_trove_set_flags(pjs->join_entries, jo_deny_indexes,
-							 PGPA_TE_CONFLICTING);
+							 PGPA_FB_CONFLICTING);
 		pgpa_trove_set_flags(pjs->rel_entries, jo_deny_rel_indexes,
-							 PGPA_TE_CONFLICTING);
+							 PGPA_FB_CONFLICTING);
 	}
 
 	/*
@@ -1115,15 +1114,15 @@ pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
 	 */
 	if (jm_conflict)
 		pgpa_trove_set_flags(pjs->join_entries, jm_indexes,
-							 PGPA_TE_CONFLICTING);
+							 PGPA_FB_CONFLICTING);
 
 	/* If semijoin advice says both yes and no, mark it all as conflicting. */
 	if (sj_permit_indexes != NULL && sj_deny_indexes != NULL)
 	{
 		pgpa_trove_set_flags(pjs->join_entries, sj_permit_indexes,
-							 PGPA_TE_CONFLICTING);
+							 PGPA_FB_CONFLICTING);
 		pgpa_trove_set_flags(pjs->join_entries, sj_deny_indexes,
-							 PGPA_TE_CONFLICTING);
+							 PGPA_FB_CONFLICTING);
 	}
 
 	/*
@@ -1200,7 +1199,7 @@ pgpa_join_order_permits_join(int outer_count, int inner_count,
 	pgpa_advice_target *prefix_target;
 
 	/* We definitely have at least a partial match for this trove entry. */
-	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+	entry->flags |= PGPA_FB_MATCH_PARTIAL;
 
 	/*
 	 * Find the innermost sublist that contains all keys; if no sublist does,
@@ -1308,7 +1307,7 @@ pgpa_join_order_permits_join(int outer_count, int inner_count,
 		 * answer is yes.
 		 */
 		if (!sublist && outer_length + 1 == length && itm == PGPA_ITM_EQUAL)
-			entry->flags |= PGPA_TE_MATCH_FULL;
+			entry->flags |= PGPA_FB_MATCH_FULL;
 
 		return (itm == PGPA_ITM_EQUAL) ? PGPA_JO_PERMITTED : PGPA_JO_DENIED;
 	}
@@ -1334,7 +1333,7 @@ pgpa_join_order_permits_join(int outer_count, int inner_count,
 	 * joining t1-t2 to the result would still be rejected.
 	 */
 	if (!sublist)
-		entry->flags |= PGPA_TE_MATCH_FULL;
+		entry->flags |= PGPA_FB_MATCH_FULL;
 	return sublist ? PGPA_JO_DENIED : PGPA_JO_PERMITTED;
 }
 
@@ -1365,7 +1364,7 @@ pgpa_join_method_permits_join(int outer_count, int inner_count,
 	pgpa_itm_type join_itm;
 
 	/* We definitely have at least a partial match for this trove entry. */
-	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+	entry->flags |= PGPA_FB_MATCH_PARTIAL;
 
 	*restrict_method = false;
 
@@ -1382,7 +1381,7 @@ pgpa_join_method_permits_join(int outer_count, int inner_count,
 											  target);
 	if (inner_itm == PGPA_ITM_EQUAL)
 	{
-		entry->flags |= PGPA_TE_MATCH_FULL;
+		entry->flags |= PGPA_FB_MATCH_FULL;
 		*restrict_method = true;
 		return true;
 	}
@@ -1469,7 +1468,7 @@ pgpa_opaque_join_permits_join(int outer_count, int inner_count,
 	pgpa_itm_type join_itm;
 
 	/* We definitely have at least a partial match for this trove entry. */
-	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+	entry->flags |= PGPA_FB_MATCH_PARTIAL;
 
 	*restrict_method = false;
 
@@ -1481,7 +1480,7 @@ pgpa_opaque_join_permits_join(int outer_count, int inner_count,
 		 * We have an exact match, and should therefore allow the join and
 		 * enforce the use of the relevant opaque join method.
 		 */
-		entry->flags |= PGPA_TE_MATCH_FULL;
+		entry->flags |= PGPA_FB_MATCH_FULL;
 		*restrict_method = true;
 		return true;
 	}
@@ -1539,7 +1538,7 @@ pgpa_semijoin_permits_join(int outer_count, int inner_count,
 	*restrict_method = false;
 
 	/* We definitely have at least a partial match for this trove entry. */
-	entry->flags |= PGPA_TE_MATCH_PARTIAL;
+	entry->flags |= PGPA_FB_MATCH_PARTIAL;
 
 	/*
 	 * If outer rel is the nullable side and contains exactly the same
@@ -1555,7 +1554,7 @@ pgpa_semijoin_permits_join(int outer_count, int inner_count,
 											  rids, target);
 	if (outer_itm == PGPA_ITM_EQUAL)
 	{
-		entry->flags |= PGPA_TE_MATCH_FULL;
+		entry->flags |= PGPA_FB_MATCH_FULL;
 		if (outer_is_nullable)
 		{
 			*restrict_method = true;
@@ -1571,7 +1570,7 @@ pgpa_semijoin_permits_join(int outer_count, int inner_count,
 											  target);
 	if (inner_itm == PGPA_ITM_EQUAL)
 	{
-		entry->flags |= PGPA_TE_MATCH_FULL;
+		entry->flags |= PGPA_FB_MATCH_FULL;
 		if (!outer_is_nullable)
 		{
 			*restrict_method = true;
@@ -1798,7 +1797,7 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 
 			/* Mark advice as inapplicable. */
 			pgpa_trove_set_flags(scan_entries, scan_type_indexes,
-								 PGPA_TE_INAPPLICABLE);
+								 PGPA_FB_INAPPLICABLE);
 		}
 		else
 		{
@@ -1815,9 +1814,9 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 	 * Mark all the scan method entries as fully matched; and if they specify
 	 * different things, mark them all as conflicting.
 	 */
-	flags = PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL;
+	flags = PGPA_FB_MATCH_PARTIAL | PGPA_FB_MATCH_FULL;
 	if (scan_type_conflict)
-		flags |= PGPA_TE_CONFLICTING;
+		flags |= PGPA_FB_CONFLICTING;
 	pgpa_trove_set_flags(scan_entries, scan_type_indexes, flags);
 	pgpa_trove_set_flags(rel_entries, scan_type_rel_indexes, flags);
 
@@ -1826,11 +1825,11 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
 	 * the ones that included this relation as a target by itself as fully
 	 * matched. If there was a conflict, mark them all as conflicting.
 	 */
-	flags = PGPA_TE_MATCH_PARTIAL;
+	flags = PGPA_FB_MATCH_PARTIAL;
 	if (gather_conflict)
-		flags |= PGPA_TE_CONFLICTING;
+		flags |= PGPA_FB_CONFLICTING;
 	pgpa_trove_set_flags(rel_entries, gather_partial_match, flags);
-	flags |= PGPA_TE_MATCH_FULL;
+	flags |= PGPA_FB_MATCH_FULL;
 	pgpa_trove_set_flags(rel_entries, gather_full_match, flags);
 
 	/*
@@ -1856,7 +1855,7 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
  *
  * Feedback entries are generated from the trove entry's flags. It's assumed
  * that the caller has already set all relevant flags with the exception of
- * PGPA_TE_FAILED. We set that flag here if appropriate.
+ * PGPA_FB_FAILED. We set that flag here if appropriate.
  */
 static List *
 pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
@@ -1878,10 +1877,10 @@ pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
 		 * from this plan would produce such an entry. If not, label the entry
 		 * as failed.
 		 */
-		if ((entry->flags & PGPA_TE_MATCH_FULL) != 0 &&
+		if ((entry->flags & PGPA_FB_MATCH_FULL) != 0 &&
 			!pgpa_walker_would_advise(walker, rt_identifiers,
 									  entry->tag, entry->target))
-			entry->flags |= PGPA_TE_FAILED;
+			entry->flags |= PGPA_FB_FAILED;
 
 		item = makeDefElem(pgpa_cstring_trove_entry(entry),
 						   (Node *) makeInteger(entry->flags), -1);
@@ -1895,7 +1894,7 @@ pgpa_planner_append_feedback(List *list, pgpa_trove *trove,
  * Emit a WARNING to tell the user about a problem with the supplied plan
  * advice.
  */
-static void
+void
 pgpa_planner_feedback_warning(List *feedback)
 {
 	StringInfoData detailbuf;
@@ -1920,7 +1919,7 @@ pgpa_planner_feedback_warning(List *feedback)
 		 * NB: Feedback should never be marked fully matched without also
 		 * being marked partially matched.
 		 */
-		if (flags == (PGPA_TE_MATCH_PARTIAL | PGPA_TE_MATCH_FULL))
+		if (flags == (PGPA_FB_MATCH_PARTIAL | PGPA_FB_MATCH_FULL))
 			continue;
 
 		/*
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
index 32808b26594..366142a0c92 100644
--- a/contrib/pg_plan_advice/pgpa_planner.h
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -76,4 +76,7 @@ typedef struct pgpa_planner_info
  */
 extern int	pgpa_planner_generate_advice;
 
+/* Must be exported for use by test_plan_advice */
+extern PGDLLEXPORT void pgpa_planner_feedback_warning(List *feedback);
+
 #endif
diff --git a/contrib/pg_plan_advice/pgpa_trove.c b/contrib/pg_plan_advice/pgpa_trove.c
index 7ade0b5ca9c..05a73cb84dd 100644
--- a/contrib/pg_plan_advice/pgpa_trove.c
+++ b/contrib/pg_plan_advice/pgpa_trove.c
@@ -23,6 +23,7 @@
  */
 #include "postgres.h"
 
+#include "pg_plan_advice.h"
 #include "pgpa_trove.h"
 
 #include "common/hashfn_unstable.h"
@@ -321,7 +322,7 @@ pgpa_cstring_trove_entry(pgpa_trove_entry *entry)
 }
 
 /*
- * Set PGPA_TE_* flags on a set of trove entries.
+ * Set PGPA_FB_* flags on a set of trove entries.
  */
 void
 pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
@@ -337,26 +338,26 @@ pgpa_trove_set_flags(pgpa_trove_entry *entries, Bitmapset *indexes, int flags)
 }
 
 /*
- * Append a string representation of the specified PGPA_TE_* flags to the
+ * Append a string representation of the specified PGPA_FB_* flags to the
  * given StringInfo.
  */
 void
 pgpa_trove_append_flags(StringInfo buf, int flags)
 {
-	if ((flags & PGPA_TE_MATCH_FULL) != 0)
+	if ((flags & PGPA_FB_MATCH_FULL) != 0)
 	{
-		Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+		Assert((flags & PGPA_FB_MATCH_PARTIAL) != 0);
 		appendStringInfo(buf, "matched");
 	}
-	else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+	else if ((flags & PGPA_FB_MATCH_PARTIAL) != 0)
 		appendStringInfo(buf, "partially matched");
 	else
 		appendStringInfo(buf, "not matched");
-	if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+	if ((flags & PGPA_FB_INAPPLICABLE) != 0)
 		appendStringInfo(buf, ", inapplicable");
-	if ((flags & PGPA_TE_CONFLICTING) != 0)
+	if ((flags & PGPA_FB_CONFLICTING) != 0)
 		appendStringInfo(buf, ", conflicting");
-	if ((flags & PGPA_TE_FAILED) != 0)
+	if ((flags & PGPA_FB_FAILED) != 0)
 		appendStringInfo(buf, ", failed");
 }
 
diff --git a/contrib/pg_plan_advice/pgpa_trove.h b/contrib/pg_plan_advice/pgpa_trove.h
index 22fe3a620f7..f3afc96d666 100644
--- a/contrib/pg_plan_advice/pgpa_trove.h
+++ b/contrib/pg_plan_advice/pgpa_trove.h
@@ -19,36 +19,6 @@
 
 typedef struct pgpa_trove pgpa_trove;
 
-/*
- * Flags that can be set on a pgpa_trove_entry to indicate what happened when
- * trying to plan using advice.
- *
- * PGPA_TE_MATCH_PARTIAL means that we found some part of the query that at
- * least partially matched the target; e.g. given JOIN_ORDER(a b), this would
- * be set if we ever saw any joinrel including either "a" or "b".
- *
- * PGPA_TE_MATCH_FULL means that we found an exact match for the target; e.g.
- * given JOIN_ORDER(a b), this would be set if we saw a joinrel containing
- * exactly "a" and "b" and nothing else.
- *
- * PGPA_TE_INAPPLICABLE means that the advice doesn't properly apply to the
- * target; e.g. INDEX_SCAN(foo bar_idx) would be so marked if bar_idx does not
- * exist on foo. The fact that this bit has been set does not mean that the
- * advice had no effect.
- *
- * PGPA_TE_CONFLICTING means that a conflict was detected between what this
- * advice wants and what some other plan advice wants; e.g. JOIN_ORDER(a b)
- * would conflict with HASH_JOIN(a), because the former requires "a" to be the
- * outer table while the latter requires it to be the inner table.
- *
- * PGPA_TE_FAILED means that the resulting plan did not conform to the advice.
- */
-#define PGPA_TE_MATCH_PARTIAL		0x0001
-#define PGPA_TE_MATCH_FULL			0x0002
-#define PGPA_TE_INAPPLICABLE		0x0004
-#define PGPA_TE_CONFLICTING			0x0008
-#define PGPA_TE_FAILED				0x0010
-
 /*
  * Each entry in a trove of advice represents the application of a tag to
  * a single target.
-- 
2.51.0



  [application/octet-stream] v26-0005-pg_plan_advice-Fix-a-bug-when-a-subquery-is-prun.patch (4.2K, 3-v26-0005-pg_plan_advice-Fix-a-bug-when-a-subquery-is-prun.patch)
  download | inline diff:
From 9350af26a88bc82b38eaf63fbea4ceaf80fd8690 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 7 Apr 2026 16:56:33 -0400
Subject: [PATCH v26 5/5] pg_plan_advice: Fix a bug when a subquery is pruned
 away entirely.

If a subquery is proven empty, and if that subquery contained a
semijoin, and if making one side or the other of that semijoin
unique and performing an inner join was a possible strategy, then
the previous code would fail with ERROR: no rtoffset for plan %s
when attempting to generate advice. Fix that.

Reported-by: Alexander Lakhin <[email protected]>
---
 contrib/pg_plan_advice/expected/semijoin.out | 17 ++++++++++++
 contrib/pg_plan_advice/pgpa_planner.c        | 28 +++++++++++---------
 contrib/pg_plan_advice/sql/semijoin.sql      |  9 +++++++
 3 files changed, 42 insertions(+), 12 deletions(-)

diff --git a/contrib/pg_plan_advice/expected/semijoin.out b/contrib/pg_plan_advice/expected/semijoin.out
index 5551c028a1f..680de215117 100644
--- a/contrib/pg_plan_advice/expected/semijoin.out
+++ b/contrib/pg_plan_advice/expected/semijoin.out
@@ -375,3 +375,20 @@ SELECT * FROM generate_series(1,1000) g, sj_narrow s WHERE g = s.val1;
 (13 rows)
 
 COMMIT;
+-- Test the case where the subquery containing a semijoin is removed from
+-- the query entirely; this test is just to make sure that advice generation
+-- does not fail.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM
+	(SELECT * FROM sj_narrow WHERE id IN (SELECT val1 FROM sj_wide)
+	 LIMIT 1) x,
+	LATERAL (SELECT 1 WHERE false) y;
+        QUERY PLAN        
+--------------------------
+ Result
+   Replaces: Scan on x
+   One-Time Filter: false
+ Generated Plan Advice:
+   NO_GATHER(x)
+(5 rows)
+
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
index 86b37e79b57..72ef3230abc 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -2065,6 +2065,9 @@ pgpa_compute_rt_identifier(pgpa_planner_info *proot, PlannerInfo *root,
 /*
  * Compute the range table offset for each pgpa_planner_info for which it
  * is possible to meaningfully do so.
+ *
+ * For pgpa_planner_info objects for which no RT offset can be computed,
+ * clear sj_unique_rels, which is meaningless in such cases.
  */
 static void
 pgpa_compute_rt_offsets(pgpa_planner_state *pps, PlannedStmt *pstmt)
@@ -2096,23 +2099,24 @@ pgpa_compute_rt_offsets(pgpa_planner_state *pps, PlannedStmt *pstmt)
 				 * there's no fixed rtoffset that we can apply to the RTIs
 				 * used during planning to locate the corresponding relations.
 				 */
-				if (rtinfo->dummy)
+				if (!rtinfo->dummy)
 				{
-					/*
-					 * 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;
 				}
-				Assert(!proot->has_rtoffset);
-				proot->has_rtoffset = true;
-				proot->rtoffset = rtinfo->rtoffset;
 				break;
 			}
 		}
+
+		/*
+		 * If we didn't end up setting has_rtoffset, then it will not be
+		 * possible to make any effective use of sj_unique_rels, and it also
+		 * won't be important to do so.  So just throw the list away to avoid
+		 * confusing pgpa_plan_walker.
+		 */
+		if (!proot->has_rtoffset)
+			proot->sj_unique_rels = NIL;
 	}
 }
 
diff --git a/contrib/pg_plan_advice/sql/semijoin.sql b/contrib/pg_plan_advice/sql/semijoin.sql
index 5a4ae52d1d9..873f0d3766c 100644
--- a/contrib/pg_plan_advice/sql/semijoin.sql
+++ b/contrib/pg_plan_advice/sql/semijoin.sql
@@ -116,3 +116,12 @@ SET LOCAL pg_plan_advice.advice = 'semijoin_unique(g)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 SELECT * FROM generate_series(1,1000) g, sj_narrow s WHERE g = s.val1;
 COMMIT;
+
+-- Test the case where the subquery containing a semijoin is removed from
+-- the query entirely; this test is just to make sure that advice generation
+-- does not fail.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM
+	(SELECT * FROM sj_narrow WHERE id IN (SELECT val1 FROM sj_wide)
+	 LIMIT 1) x,
+	LATERAL (SELECT 1 WHERE false) y;
-- 
2.51.0



  [application/octet-stream] v26-0002-test_plan_advice-Guard-against-advice-instabilit.patch (11.5K, 4-v26-0002-test_plan_advice-Guard-against-advice-instabilit.patch)
  download | inline diff:
From 603c2e3d20982bb0655c421d6ebe5f227af576cf Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 7 Apr 2026 13:46:04 -0400
Subject: [PATCH v26 2/5] test_plan_advice: Guard against advice instability by
 replanning.

It turns out that our main regression test suite queries tables upon
which concurrent DDL is occurring, which can, rarely, cause
test_plan_advice failures. For example, if test_plan_advice plans
the query and generates INDEX_SCAN(a b) advice, and then the index
is dropped before the query is replanned with that as the supplied
advice, the advice will not apply cleanly and the tests will fail.
Such failures are apparently quite rare, but they do occur.

In an attempt to reduce the failure rate to something negligible,
this commit makes test_plan_advice drive the main planner in a
loop. We plan once, generating advice; then again, applying that
advice. If the advice doesn't apply cleanly, we do the whole thing
over again from the top. If it fails in the same way twice (same
advice, same feedback) or if we run out of retries, we emit a
warning and stop. Problems that change or vanish on retry are
assumed to be ephemeral.

Reported-by: Tom Lane <[email protected]>
---
 .../test_plan_advice/t/001_replan_regress.pl  |   2 +-
 .../test_plan_advice/test_plan_advice.c       | 233 +++++++++++++++---
 2 files changed, 204 insertions(+), 31 deletions(-)

diff --git a/src/test/modules/test_plan_advice/t/001_replan_regress.pl b/src/test/modules/test_plan_advice/t/001_replan_regress.pl
index 38ffa4d11ae..e43b80bc85e 100644
--- a/src/test/modules/test_plan_advice/t/001_replan_regress.pl
+++ b/src/test/modules/test_plan_advice/t/001_replan_regress.pl
@@ -19,7 +19,7 @@ $node->init();
 $node->append_conf('postgresql.conf', <<EOM);
 shared_preload_libraries='test_plan_advice'
 pg_plan_advice.always_explain_supplied_advice=false
-pg_plan_advice.feedback_warnings=true
+test_plan_advice.max_attempts=3
 EOM
 $node->start;
 
diff --git a/src/test/modules/test_plan_advice/test_plan_advice.c b/src/test/modules/test_plan_advice/test_plan_advice.c
index cff5039b5c8..c17115707b1 100644
--- a/src/test/modules/test_plan_advice/test_plan_advice.c
+++ b/src/test/modules/test_plan_advice/test_plan_advice.c
@@ -19,20 +19,38 @@
 #include "postgres.h"
 
 #include "access/xact.h"
+#include "commands/defrem.h"
 #include "fmgr.h"
 #include "optimizer/optimizer.h"
+#include "optimizer/planner.h"
 #include "pg_plan_advice.h"
 #include "utils/guc.h"
 
 PG_MODULE_MAGIC;
 
+static int	test_plan_advice_max_attempts = 1;
 static bool in_recursion = false;
+static void (*feedback_warning_fn) (List *feedback);
+static planner_hook_type prev_planner_hook = NULL;
 
+static PlannedStmt *test_plan_advice_planner(Query *parse,
+											 const char *query_string,
+											 int cursorOptions,
+											 ParamListInfo boundParams,
+											 ExplainState *es);
 static char *test_plan_advice_advisor(PlannerGlobal *glob,
 									  Query *parse,
 									  const char *query_string,
 									  int cursorOptions,
 									  ExplainState *es);
+static PlannedStmt *copy_and_plan_query(Query *parse,
+										const char *query_string,
+										int cursorOptions,
+										ParamListInfo boundParams,
+										ExplainState *es,
+										bool suppress_messages);
+static List *extract_feedback(PlannedStmt *pstmt);
+static bool all_feedback_is_ok(List *feedback);
 static DefElem *find_defelem_by_defname(List *deflist, char *defname);
 
 /*
@@ -43,6 +61,21 @@ _PG_init(void)
 {
 	void		(*add_advisor_fn) (pg_plan_advice_advisor_hook hook);
 
+	DefineCustomIntVariable("test_plan_advice.max_attempts",
+							"Maximum number of planning attempts before "
+							"reporting feedback warnings.",
+							NULL,
+							&test_plan_advice_max_attempts,
+							1, 1, 10,
+							PGC_USERSET,
+							0, NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("test_plan_advice");
+
+	/* Install our planner hook. */
+	prev_planner_hook = planner_hook;
+	planner_hook = test_plan_advice_planner;
+
 	/*
 	 * Ask pg_plan_advice to get advice strings from test_plan_advice_advisor
 	 */
@@ -51,6 +84,78 @@ _PG_init(void)
 							   true, NULL);
 
 	(*add_advisor_fn) (test_plan_advice_advisor);
+
+	/*
+	 * Get a pointer to pg_plan_advice's function for emitting feedback
+	 * warnings.
+	 */
+	feedback_warning_fn =
+		load_external_function("pg_plan_advice",
+							   "pgpa_planner_feedback_warning",
+							   true, NULL);
+}
+
+/*
+ * Planner hook that retries planning when feedback indicates a problem.
+ *
+ * When the catalog changes between the first plan (which generates advice)
+ * and the second plan (which uses that advice), the advice can reference
+ * objects that no longer exist or reflect stale statistics.  To avoid
+ * spurious warnings, we retry planning up to test_plan_advice.max_attempts
+ * times.  If the feedback stabilizes (i.e. is the same as the previous
+ * attempt), we conclude the problem is genuine and emit warnings.
+ */
+static PlannedStmt *
+test_plan_advice_planner(Query *parse, const char *query_string,
+						 int cursorOptions, ParamListInfo boundParams,
+						 ExplainState *es)
+{
+	PlannedStmt *pstmt;
+	List	   *feedback;
+	List	   *prev_feedback = NIL;
+
+	for (int i = 0; i < test_plan_advice_max_attempts; ++i)
+	{
+		/*
+		 * Try planning the query. On the first iteration, we don't need or
+		 * want to suppress any warnings or other chatter that the planner is
+		 * going to generate, because our goal here is to get the same output
+		 * that would have occurred without this module. But on the second and
+		 * later iterations, that output has already been produced, so we
+		 * don't want it to appear again.
+		 */
+		pstmt = copy_and_plan_query(parse, query_string, cursorOptions,
+									boundParams, es, (i > 0));
+
+		/* Extract feedback. */
+		feedback = extract_feedback(pstmt);
+
+		/* If no problems were detected, stop. */
+		if (all_feedback_is_ok(feedback))
+			break;
+
+		/*
+		 * If the feedback is the same as last time, then apparently there's
+		 * a real problem, so emit warnings and stop. If this is the last
+		 * iteration, it's less clear that there's a real problem, but if not,
+		 * the user hasn't set the maximum number of retries high enough, so
+		 * handle that case the same way.
+		 */
+		if (equal(feedback, prev_feedback) ||
+			i == test_plan_advice_max_attempts - 1)
+		{
+			(*feedback_warning_fn) (feedback);
+			break;
+		}
+
+		/*
+		 * Go around and try it again, with the newly-generated feedback as
+		 * the new point of comparison.
+		 */
+		prev_feedback = feedback;
+	}
+
+	return pstmt;
 }
 
 /*
@@ -63,7 +168,6 @@ test_plan_advice_advisor(PlannerGlobal *glob, Query *parse,
 						 ExplainState *es)
 {
 	PlannedStmt *pstmt;
-	int			save_nestlevel = 0;
 	DefElem    *pgpa_item;
 	DefElem    *advice_string_item;
 
@@ -74,35 +178,18 @@ test_plan_advice_advisor(PlannerGlobal *glob, Query *parse,
 	if (in_recursion)
 		return NULL;
 
+	/*
+	 * Try planning the query, generating advice in the process. We ask
+	 * copy_and_plan_query to adjust client_min_messages; otherwise, any
+	 * messages that are generated during planning would appear here and again
+	 * when the query is replanned with the advice string.
+	 */
 	PG_TRY();
 	{
 		in_recursion = true;
 
-		/*
-		 * Planning can trigger expression evaluation, which can result in
-		 * sending NOTICE messages or other output to the client. To avoid
-		 * that, we set client_min_messages = ERROR in the hopes of getting
-		 * the same output with and without this module.
-		 *
-		 * We also need to set pg_plan_advice.always_store_advice_details so
-		 * that pg_plan_advice will generate an advice string, since the whole
-		 * point of this function is to get access to that.
-		 */
-		save_nestlevel = NewGUCNestLevel();
-		set_config_option("client_min_messages", "error",
-						  PGC_SUSET, PGC_S_SESSION,
-						  GUC_ACTION_SAVE, true, 0, false);
-		set_config_option("pg_plan_advice.always_store_advice_details", "true",
-						  PGC_SUSET, PGC_S_SESSION,
-						  GUC_ACTION_SAVE, true, 0, false);
-
-		/*
-		 * Replan. We must copy the Query, because the planner modifies it.
-		 * (As noted elsewhere, that's unfortunate; perhaps it will be fixed
-		 * some day.)
-		 */
-		pstmt = planner(copyObject(parse), query_string, cursorOptions,
-						glob->boundParams, es);
+		pstmt = copy_and_plan_query(parse, query_string, cursorOptions,
+									glob->boundParams, es, true);
 	}
 	PG_FINALLY();
 	{
@@ -110,10 +197,6 @@ test_plan_advice_advisor(PlannerGlobal *glob, Query *parse,
 	}
 	PG_END_TRY();
 
-	/* Roll back any GUC changes */
-	if (save_nestlevel > 0)
-		AtEOXact_GUC(false, save_nestlevel);
-
 	/* Extract and return the advice string */
 	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
 										"pg_plan_advice");
@@ -127,6 +210,96 @@ test_plan_advice_advisor(PlannerGlobal *glob, Query *parse,
 	return strVal(advice_string_item->arg);
 }
 
+/*
+ * Wrapper around the main query planner.
+ */
+static PlannedStmt *
+copy_and_plan_query(Query *parse, const char *query_string, int cursorOptions,
+					ParamListInfo boundParams, ExplainState *es,
+					bool suppress_messages)
+{
+	int			save_nestlevel = 0;
+	PlannedStmt *pstmt;
+
+	/*
+	 * Temporarily set pg_plan_advice.always_store_advice_details. Either
+	 * we're being called to generate advice, in which case setting this GUC
+	 * is important to make sure that we do, or we're being called to see
+	 * whether supplied advice applied properly, in which case this is needed
+	 * so that pg_plan_advice will provide feedback.
+	 */
+	save_nestlevel = NewGUCNestLevel();
+	set_config_option("pg_plan_advice.always_store_advice_details",
+					  "true",
+					  PGC_SUSET, PGC_S_SESSION,
+					  GUC_ACTION_SAVE, true, 0, false);
+
+	/*
+	 * Planning can trigger expression evaluation, which can result in sending
+	 * NOTICE messages or other output to the client. To avoid that, we allow
+	 * the caller to request client_min_messages = ERROR in the hopes of
+	 * getting the same output with and without this module.
+	 */
+	if (suppress_messages)
+		set_config_option("client_min_messages", "error",
+						  PGC_SUSET, PGC_S_SESSION,
+						  GUC_ACTION_SAVE, true, 0, false);
+
+	/*
+	 * We must copy the Query, because the planner modifies it, and we intend
+	 * to plan it multiple times. (As noted elsewhere, that's unfortunate;
+	 * perhaps it will be fixed some day.)
+	 */
+	if (prev_planner_hook)
+		pstmt = (*prev_planner_hook) (copyObject(parse), query_string,
+									  cursorOptions, boundParams, es);
+	else
+		pstmt = standard_planner(copyObject(parse), query_string,
+								 cursorOptions, boundParams, es);
+
+	/* Roll back any GUC changes */
+	AtEOXact_GUC(false, save_nestlevel);
+
+	/* And we're done. */
+	return pstmt;
+}
+
+/*
+ * Extract the feedback list from a PlannedStmt's extension_state.
+ * Returns NIL if no feedback is present.
+ */
+static List *
+extract_feedback(PlannedStmt *pstmt)
+{
+	DefElem    *pgpa_item;
+	DefElem    *feedback_item;
+
+	pgpa_item = find_defelem_by_defname(pstmt->extension_state,
+										"pg_plan_advice");
+	if (pgpa_item == NULL)
+		return NIL;
+	feedback_item = find_defelem_by_defname((List *) pgpa_item->arg,
+											"feedback");
+	if (feedback_item == NULL)
+		return NIL;
+	return (List *) feedback_item->arg;
+}
+
+/*
+ * Check whether a feedback list indicates that all advice was applied
+ * successfully.
+ */
+static bool
+all_feedback_is_ok(List *feedback)
+{
+	foreach_node(DefElem, item, feedback)
+	{
+		if (defGetInt32(item) != (PGPA_FB_MATCH_PARTIAL | PGPA_FB_MATCH_FULL))
+			return false;
+	}
+	return true;
+}
+
 /*
  * Search a list of DefElem objects for a given defname.
  */
-- 
2.51.0



  [application/octet-stream] v26-0003-pg_plan_advice-Handle-non-repeatable-TABLESAMPLE.patch (8.5K, 5-v26-0003-pg_plan_advice-Handle-non-repeatable-TABLESAMPLE.patch)
  download | inline diff:
From 0fce35fcb4125cab73358dd122fbbaa810aee5f5 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 7 Apr 2026 16:17:36 -0400
Subject: [PATCH v26 3/5] pg_plan_advice: Handle non-repeatable TABLESAMPLE
 scans.

When a tablesample routine says that it is not repeatable across
scans, set_tablesample_rel_pathlist will (usually) materialize it,
confusing pg_plan_advice's plan walker machinery. To fix, update that
machinery to view such Material paths as essentially an extension of
the underlying scan.

Reported-by: Alexander Lakhin <[email protected]>
---
 contrib/pg_plan_advice/Makefile          |  2 ++
 contrib/pg_plan_advice/expected/scan.out | 38 ++++++++++++++++++++++++
 contrib/pg_plan_advice/pgpa_join.c       | 12 +++++---
 contrib/pg_plan_advice/pgpa_scan.c       | 11 +++++++
 contrib/pg_plan_advice/pgpa_walker.c     | 29 ++++++++++++++++++
 contrib/pg_plan_advice/pgpa_walker.h     |  1 +
 contrib/pg_plan_advice/sql/scan.sql      | 12 ++++++++
 7 files changed, 101 insertions(+), 4 deletions(-)

diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
index cd478dc1a6d..cbafc50ca4c 100644
--- a/contrib/pg_plan_advice/Makefile
+++ b/contrib/pg_plan_advice/Makefile
@@ -22,6 +22,8 @@ PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
 REGRESS = gather join_order join_strategy partitionwise prepared \
 	scan semijoin syntax
 
+EXTRA_INSTALL = contrib/tsm_system_time
+
 EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
 
 ifdef USE_PGXS
diff --git a/contrib/pg_plan_advice/expected/scan.out b/contrib/pg_plan_advice/expected/scan.out
index 44ce40f33a6..f4036e4cbdd 100644
--- a/contrib/pg_plan_advice/expected/scan.out
+++ b/contrib/pg_plan_advice/expected/scan.out
@@ -770,3 +770,41 @@ SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
 (7 rows)
 
 COMMIT;
+-- Test a non-repeatable tablesample method with a scan-level Materialize.
+CREATE EXTENSION tsm_system_time;
+CREATE TABLE scan_tsm (i int);
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT 1 FROM (SELECT i FROM scan_tsm TABLESAMPLE system_time (1000)),
+	LATERAL (SELECT i LIMIT 1);
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ Nested Loop
+   ->  Materialize
+         ->  Sample Scan on scan_tsm
+               Sampling: system_time ('1000'::double precision)
+   ->  Limit
+         ->  Result
+ Generated Plan Advice:
+   JOIN_ORDER(scan_tsm unnamed_subquery#2)
+   NESTED_LOOP_PLAIN(unnamed_subquery#2)
+   NO_GATHER(unnamed_subquery#2 scan_tsm "*RESULT*"@unnamed_subquery)
+(10 rows)
+
+-- Same, but with the scan-level Materialize on the inner side of a join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT 1 FROM (SELECT 1 AS x LIMIT 1),
+	LATERAL (SELECT x FROM scan_tsm TABLESAMPLE system_time (1000));
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Nested Loop
+   ->  Limit
+         ->  Result
+   ->  Materialize
+         ->  Sample Scan on scan_tsm
+               Sampling: system_time ('1000'::double precision)
+ Generated Plan Advice:
+   JOIN_ORDER(unnamed_subquery scan_tsm)
+   NESTED_LOOP_PLAIN(scan_tsm)
+   NO_GATHER(unnamed_subquery scan_tsm "*RESULT*"@unnamed_subquery)
+(10 rows)
+
diff --git a/contrib/pg_plan_advice/pgpa_join.c b/contrib/pg_plan_advice/pgpa_join.c
index 4610d02356f..38e7b91ed7e 100644
--- a/contrib/pg_plan_advice/pgpa_join.c
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -363,9 +363,11 @@ pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
 			/*
 			 * The planner may have chosen to place a Material node on the
 			 * inner side of the MergeJoin; if this is present, we record it
-			 * as part of the join strategy.
+			 * as part of the join strategy. (However, scan-level Materialize
+			 * nodes are an exception.)
 			 */
-			if (elidedinner == NULL && IsA(innerplan, Material))
+			if (elidedinner == NULL && IsA(innerplan, Material) &&
+				!pgpa_is_scan_level_materialize(innerplan))
 			{
 				elidedinner = pgpa_descend_node(pstmt, &innerplan);
 				strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
@@ -390,9 +392,11 @@ pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
 			/*
 			 * The planner may have chosen to place a Material or Memoize node
 			 * on the inner side of the NestLoop; if this is present, we
-			 * record it as part of the join strategy.
+			 * record it as part of the join strategy. (However, scan-level
+			 * Materialize nodes are an exception.)
 			 */
-			if (elidedinner == NULL && IsA(innerplan, Material))
+			if (elidedinner == NULL && IsA(innerplan, Material) &&
+				!pgpa_is_scan_level_materialize(innerplan))
 			{
 				elidedinner = pgpa_descend_node(pstmt, &innerplan);
 				strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
diff --git a/contrib/pg_plan_advice/pgpa_scan.c b/contrib/pg_plan_advice/pgpa_scan.c
index 0467f9b12ba..21b58a0ac42 100644
--- a/contrib/pg_plan_advice/pgpa_scan.c
+++ b/contrib/pg_plan_advice/pgpa_scan.c
@@ -120,6 +120,17 @@ pgpa_build_scan(pgpa_plan_walker_context *walker, Plan *plan,
 				break;
 		}
 	}
+	else if (pgpa_is_scan_level_materialize(plan))
+	{
+		/*
+		 * Non-repeatable tablesample methods can be wrapped in a Materialize
+		 * node that must be treated as part of the scan itself. See
+		 * set_tablesample_rel_pathlist().
+		 */
+		rti = pgpa_scanrelid(plan->lefttree);
+		relids = bms_make_singleton(rti);
+		strategy = PGPA_SCAN_ORDINARY;
+	}
 	else if ((relids = pgpa_relids(plan)) != NULL)
 	{
 		switch (nodeTag(plan))
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
index e32684d2075..e49361ae266 100644
--- a/contrib/pg_plan_advice/pgpa_walker.c
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -16,6 +16,7 @@
 #include "pgpa_scan.h"
 #include "pgpa_walker.h"
 
+#include "access/tsmapi.h"
 #include "nodes/plannodes.h"
 #include "parser/parsetree.h"
 #include "utils/lsyscache.h"
@@ -609,6 +610,34 @@ pgpa_scanrelid(Plan *plan)
 	}
 }
 
+/*
+ * Check whether a plan node is a Material node that should be treated as
+ * a scan. Currently, this only happens when set_tablesample_rel_pathlist
+ * inserts a Material node to protect a SampleScan that uses a non-repeatable
+ * tablesample method.
+ *
+ * (Most Material nodes we're likely to encounter are actually part of the
+ * join strategy: nested loops and merge joins can choose to materialize the
+ * inner sides of the join. The cases identified here are the rare
+ * exceptions.)
+ */
+bool
+pgpa_is_scan_level_materialize(Plan *plan)
+{
+	Plan	   *child;
+	SampleScan *sscan;
+	TsmRoutine *tsm;
+
+	if (!IsA(plan, Material))
+		return false;
+	child = plan->lefttree;
+	if (child == NULL || !IsA(child, SampleScan))
+		return false;
+	sscan = (SampleScan *) child;
+	tsm = GetTsmRoutine(sscan->tablesample->tsmhandler);
+	return !tsm->repeatable_across_scans;
+}
+
 /*
  * Construct a new Bitmapset containing non-RTE_JOIN members of 'relids'.
  */
diff --git a/contrib/pg_plan_advice/pgpa_walker.h b/contrib/pg_plan_advice/pgpa_walker.h
index 47667c03374..ebe850622d3 100644
--- a/contrib/pg_plan_advice/pgpa_walker.h
+++ b/contrib/pg_plan_advice/pgpa_walker.h
@@ -114,6 +114,7 @@ extern void pgpa_add_future_feature(pgpa_plan_walker_context *walker,
 extern ElidedNode *pgpa_last_elided_node(PlannedStmt *pstmt, Plan *plan);
 extern Bitmapset *pgpa_relids(Plan *plan);
 extern Index pgpa_scanrelid(Plan *plan);
+extern bool pgpa_is_scan_level_materialize(Plan *plan);
 extern Bitmapset *pgpa_filter_out_join_relids(Bitmapset *relids, List *rtable);
 
 extern bool pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
diff --git a/contrib/pg_plan_advice/sql/scan.sql b/contrib/pg_plan_advice/sql/scan.sql
index 800ff7a4622..98bee88de91 100644
--- a/contrib/pg_plan_advice/sql/scan.sql
+++ b/contrib/pg_plan_advice/sql/scan.sql
@@ -196,3 +196,15 @@ SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
 COMMIT;
+
+-- Test a non-repeatable tablesample method with a scan-level Materialize.
+CREATE EXTENSION tsm_system_time;
+CREATE TABLE scan_tsm (i int);
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT 1 FROM (SELECT i FROM scan_tsm TABLESAMPLE system_time (1000)),
+	LATERAL (SELECT i LIMIT 1);
+
+-- Same, but with the scan-level Materialize on the inner side of a join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT 1 FROM (SELECT 1 AS x LIMIT 1),
+	LATERAL (SELECT x FROM scan_tsm TABLESAMPLE system_time (1000));
-- 
2.51.0



  [application/octet-stream] v26-0004-pg_plan_advice-Add-alternatives-test-to-Makefile.patch (962B, 6-v26-0004-pg_plan_advice-Add-alternatives-test-to-Makefile.patch)
  download | inline diff:
From ac0678546c6086acba0ebbcda559a345fabf52b7 Mon Sep 17 00:00:00 2001
From: Robert Haas <[email protected]>
Date: Tue, 7 Apr 2026 16:18:17 -0400
Subject: [PATCH v26 4/5] pg_plan_advice: Add alternatives test to Makefile.

Oversight in commit 6455e55b0da47255f332a96f005ba0dd1c7176c2.
---
 contrib/pg_plan_advice/Makefile | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/contrib/pg_plan_advice/Makefile b/contrib/pg_plan_advice/Makefile
index cbafc50ca4c..d016723794d 100644
--- a/contrib/pg_plan_advice/Makefile
+++ b/contrib/pg_plan_advice/Makefile
@@ -19,8 +19,8 @@ HEADERS_pg_plan_advice = pg_plan_advice.h
 
 PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
 
-REGRESS = gather join_order join_strategy partitionwise prepared \
-	scan semijoin syntax
+REGRESS = alternatives gather join_order join_strategy partitionwise \
+	prepared scan semijoin syntax
 
 EXTRA_INSTALL = contrib/tsm_system_time
 
-- 
2.51.0



^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-07 22:05  Tom Lane <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Tom Lane @ 2026-04-07 22:05 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; Nathan Bossart <[email protected]>; Melanie Plageman <[email protected]>; heikki.linnakangas <[email protected]>

Robert Haas <[email protected]> writes:
> 0001 and 0002 implement the "retry a few times" idea for avoiding
> test_plan_advice failures. I argue that (a) these are reasonable
> post-commit stabilization that should not be blocked by feature freeze
> and (b) most people here will be happier with a solution like this
> that will normally cost very little than they will be with switching
> test_plan_advice to executing serially. The RMT can decide whether it
> agrees.

I'm not on the RMT, but I agree this is a nicer solution.
(I didn't read these patches in detail, but in a quick once-over
they seemed plausible.)

> The other question here is whether it's really a good idea to
> apply this now considering that we've seen only one failure so far. I
> think it's probably a good idea to do something like this before
> release, so that we hopefully reduce the false positive rate from the
> test to something much closer to zero, but I think we've still had
> only the one failure, and I'm really interested in knowing how close
> the failure rate is to zero already. The RMT may have an opinion on
> how long to wait before doing something like this, too.

No strong opinion about that.  Certainly waiting a couple of weeks
to gather more data seems reasonable.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-08 14:49  Nathan Bossart <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Nathan Bossart @ 2026-04-08 14:49 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Robert Haas <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; Melanie Plageman <[email protected]>; heikki.linnakangas <[email protected]>

On Tue, Apr 07, 2026 at 06:05:47PM -0400, Tom Lane wrote:
> Robert Haas <[email protected]> writes:
>> 0001 and 0002 implement the "retry a few times" idea for avoiding
>> test_plan_advice failures. I argue that (a) these are reasonable
>> post-commit stabilization that should not be blocked by feature freeze
>> and (b) most people here will be happier with a solution like this
>> that will normally cost very little than they will be with switching
>> test_plan_advice to executing serially. The RMT can decide whether it
>> agrees.
> 
> I'm not on the RMT, but I agree this is a nicer solution.
> (I didn't read these patches in detail, but in a quick once-over
> they seemed plausible.)
> 
>> The other question here is whether it's really a good idea to
>> apply this now considering that we've seen only one failure so far. I
>> think it's probably a good idea to do something like this before
>> release, so that we hopefully reduce the false positive rate from the
>> test to something much closer to zero, but I think we've still had
>> only the one failure, and I'm really interested in knowing how close
>> the failure rate is to zero already. The RMT may have an opinion on
>> how long to wait before doing something like this, too.
> 
> No strong opinion about that.  Certainly waiting a couple of weeks
> to gather more data seems reasonable.

I am only 1/3 of the RMT, but I am fine with the plan as stated.

-- 
nathan





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-08 16:18  Melanie Plageman <[email protected]>
  parent: Nathan Bossart <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Melanie Plageman @ 2026-04-08 16:18 UTC (permalink / raw)
  To: Nathan Bossart <[email protected]>; +Cc: Tom Lane <[email protected]>; Robert Haas <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; heikki.linnakangas <[email protected]>

On Wed, Apr 8, 2026 at 10:49 AM Nathan Bossart <[email protected]> wrote:
>
> On Tue, Apr 07, 2026 at 06:05:47PM -0400, Tom Lane wrote:
> > Robert Haas <[email protected]> writes:
> >
> >> The other question here is whether it's really a good idea to
> >> apply this now considering that we've seen only one failure so far. I
> >> think it's probably a good idea to do something like this before
> >> release, so that we hopefully reduce the false positive rate from the
> >> test to something much closer to zero, but I think we've still had
> >> only the one failure, and I'm really interested in knowing how close
> >> the failure rate is to zero already. The RMT may have an opinion on
> >> how long to wait before doing something like this, too.
> >
> > No strong opinion about that.  Certainly waiting a couple of weeks
> > to gather more data seems reasonable.
>
> I am only 1/3 of the RMT, but I am fine with the plan as stated.

I agree with waiting a few weeks to continue catching bugs.

As for 0001/0002 and the retry approach: if that's the best way to
avoid spurious test failures, I'm fine with it. I haven't reviewed the
code in detail and don't have an alternative to suggest. I'm
definitely against running anything serially.

As for the other ideas and suggestions so far:

I don't see a way to split up the regression test suite that wouldn't
make it harder to figure out where to add tests in the future. The
whole point is to avoid regressing pg_plan_advice when new things are
added to the planner, and that works because people don't have to
think about a pg_plan_advice -- their new test queries automatically
get coverage.

I do think there needs to be a way to run this in CI, but it doesn't
have to be on by default.

For the buildfarm, I don't have a strong opinion about whether to
limit it to some animals or some runs. Running on only some animals is
easier to reason about when you see a failure (i.e. that animal runs
with test_plan_advice, so it might be that), but running it once a day
or once a week on all animals gives broader coverage. That said, the
kind of coverage you gain from timing differences across animals --
catching races and transient issues -- may be less relevant for
test_plan_advice than for other tests.

- Melanie





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-13 16:01  Robert Haas <[email protected]>
  parent: Melanie Plageman <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-13 16:01 UTC (permalink / raw)
  To: Melanie Plageman <[email protected]>; +Cc: Nathan Bossart <[email protected]>; Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; heikki.linnakangas <[email protected]>

On Wed, Apr 8, 2026 at 12:18 PM Melanie Plageman
<[email protected]> wrote:
> > > No strong opinion about that.  Certainly waiting a couple of weeks
> > > to gather more data seems reasonable.
> >
> > I am only 1/3 of the RMT, but I am fine with the plan as stated.
>
> I agree with waiting a few weeks to continue catching bugs.

Sounds like we have a consensus. I have committed the three bug-fix
patches (unrelated to the retry-loop stuff) plus the preparatory
refactoring patch for the retry-loop patch. That renames a few
identifiers, so it seemed best to get it out of the way sooner rather
than later. I'll hold off on the main retry-loop patch for now. So far
I haven't seen any other buildfarm failures that look related to this
issue, so either I've missed some (which is certainly possible) or the
chances of failure are very low.

Thanks,

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-14 18:00  Alexander Lakhin <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Alexander Lakhin @ 2026-04-14 18:00 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; Melanie Plageman <[email protected]>; +Cc: Nathan Bossart <[email protected]>; Tom Lane <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; heikki.linnakangas <[email protected]>

13.04.2026 19:01, Robert Haas wrote:
> Sounds like we have a consensus. I have committed the three bug-fix
> patches (unrelated to the retry-loop stuff) ...

Thanks again for committing these fixes, Robert! With all the fixes in
place, I and SQLsmith have reached another error:
CREATE TABLE t1(a int);
CREATE TABLE t2(b int);

SELECT 1 FROM t1 WHERE EXISTS
   (SELECT 1 FROM
     (SELECT 1 FROM
       (SELECT 1) LEFT JOIN t2 ON true),
     t2 WHERE a = b);

ERROR:  XX000: unique semijoin found for relids (b 3 5 7) but not observed during planning
LOCATION:  pgpa_plan_walker, pgpa_walker.c:153

Could you please have a look?

Best regards,
Alexander

^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-15 10:30  Tender Wang <[email protected]>
  parent: Alexander Lakhin <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Tender Wang @ 2026-04-15 10:30 UTC (permalink / raw)
  To: Alexander Lakhin <[email protected]>; +Cc: Robert Haas <[email protected]>; Melanie Plageman <[email protected]>; Nathan Bossart <[email protected]>; Tom Lane <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; heikki.linnakangas <[email protected]>

Alexander Lakhin <[email protected]> 于2026年4月15日周三 02:00写道:
>
> 13.04.2026 19:01, Robert Haas wrote:
>
> Sounds like we have a consensus. I have committed the three bug-fix
> patches (unrelated to the retry-loop stuff) ...
>
>
> Thanks again for committing these fixes, Robert! With all the fixes in
> place, I and SQLsmith have reached another error:
> CREATE TABLE t1(a int);
> CREATE TABLE t2(b int);
>
> SELECT 1 FROM t1 WHERE EXISTS
>   (SELECT 1 FROM
>     (SELECT 1 FROM
>       (SELECT 1) LEFT JOIN t2 ON true),
>     t2 WHERE a = b);
>
> ERROR:  XX000: unique semijoin found for relids (b 3 5 7) but not observed during planning
> LOCATION:  pgpa_plan_walker, pgpa_walker.c:153
>
> Could you please have a look?
>
I did some research, and  the sj_unique_rtis contains {3,5,6,7}.
You can see that 6 is in the set. How 6 is added into the uniquerel->relids.
After deconstruct_jointree(), the joinlist is as follow:
(gdb) call nodeToString(joinlist)
$1 = 0x1ef1238 "({RANGETBLREF :rtindex 1} {RANGETBLREF :rtindex 7}
{RANGETBLREF :rtindex 5} {RANGETBLREF :rtindex 3})"
You can see that no 6 in the list.
The 6 is added when processing (7, 5), in make_join_rel(), we have below logic:

   /*
    * Add outer join relid(s) to form the canonical relids.  Any added outer
    * joins besides sjinfo itself are appended to pushed_down_joins.
    */
   joinrelids = add_outer_joins_to_relids(root, joinrelids, sjinfo,
                                 &pushed_down_joins);

In this case, 6 was added to the joinrelids.
When processing {1}, {3,5,6,7}, the {3,5,6,7} is the uniquerel, so in
the pgpa_join_path_setup(),
the {3,5,6,7} was appended to proot->sj_unique_rels.

In the plan_showdown phase, in pgpa_qf_add_plan_rtis(), we can add 7,
5, and 3 to qf->relids.
It seems difficult to add "6" to qf->relids when walking through the
plan tree.(Maybe have an easy way, I don't know too much
pg_plan_advice related code).

-- 
Thanks,
Tender Wang





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-15 19:46  Robert Haas <[email protected]>
  parent: Tender Wang <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Robert Haas @ 2026-04-15 19:46 UTC (permalink / raw)
  To: Tender Wang <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Melanie Plageman <[email protected]>; Nathan Bossart <[email protected]>; Tom Lane <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; heikki.linnakangas <[email protected]>

On Wed, Apr 15, 2026 at 6:30 AM Tender Wang <[email protected]> wrote:
> In the plan_showdown phase, in pgpa_qf_add_plan_rtis(), we can add 7,
> 5, and 3 to qf->relids.
> It seems difficult to add "6" to qf->relids when walking through the
> plan tree.(Maybe have an easy way, I don't know too much
> pg_plan_advice related code).

Thanks for looking through this.  sj_unique_rtis is actually not set
from the plan tree walk, but based on the calls to
pgpa_join_path_setup that occur during planning, so it makes sense
that the join RTI crept in there. I'm guessing that this is another
place that needs a call to pgpa_filter_out_join_relids -- I've had a
few of those bugs already.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-16 01:45  Tender Wang <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 1 reply; 133+ messages in thread

From: Tender Wang @ 2026-04-16 01:45 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Melanie Plageman <[email protected]>; Nathan Bossart <[email protected]>; Tom Lane <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; heikki.linnakangas <[email protected]>

Robert Haas <[email protected]> 于2026年4月16日周四 03:47写道:
>
> On Wed, Apr 15, 2026 at 6:30 AM Tender Wang <[email protected]> wrote:
> > In the plan_showdown phase, in pgpa_qf_add_plan_rtis(), we can add 7,
> > 5, and 3 to qf->relids.
> > It seems difficult to add "6" to qf->relids when walking through the
> > plan tree.(Maybe have an easy way, I don't know too much
> > pg_plan_advice related code).
>
> Thanks for looking through this.  sj_unique_rtis is actually not set
> from the plan tree walk, but based on the calls to
> pgpa_join_path_setup that occur during planning, so it makes sense
> that the join RTI crept in there. I'm guessing that this is another
> place that needs a call to pgpa_filter_out_join_relids -- I've had a
> few of those bugs already.
I try a quick fix as follow:
diff --git a/contrib/pg_plan_advice/pgpa_planner.c
b/contrib/pg_plan_advice/pgpa_planner.c
index 72ef3230abc..971f301e950 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -541,6 +541,7 @@ pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
        {
                pgpa_planner_state *pps;
                RelOptInfo *uniquerel;
+               Bitmapset *relids;

                uniquerel = jointype == JOIN_UNIQUE_OUTER ? outerrel : innerrel;
                pps = GetPlannerGlobalExtensionState(root->glob,
planner_extension_id);
@@ -562,8 +563,11 @@ pgpa_join_path_setup(PlannerInfo *root,
RelOptInfo *joinrel,
                        oldcontext = MemoryContextSwitchTo(pps->mcxt);
                        proot = pgpa_planner_get_proot(pps, root);
                        if (!list_member(proot->sj_unique_rels,
uniquerel->relids))
+                       {
+                               relids =
pgpa_filter_out_join_relids(uniquerel->relids, root->parse->rtable);
                                proot->sj_unique_rels =
lappend(proot->sj_unique_rels,
-
                         bms_copy(uniquerel->relids));
+
                         bms_copy(relids));
+                       }
                        MemoryContextSwitchTo(oldcontext);
                }
        }

postgres=# LOAD 'pg_plan_advice';
LOAD
postgres=# EXPLAIN (COSTS OFF, PLAN_ADVICE)SELECT 1 FROM t1 WHERE EXISTS
   (SELECT 1 FROM
     (SELECT 1 FROM
       (SELECT 1) LEFT JOIN t2 ON true),
     t2 WHERE a = b);
                    QUERY PLAN
---------------------------------------------------
 Hash Join
   Hash Cond: (t1.a = t2.b)
   ->  Seq Scan on t1
   ->  Hash
         ->  HashAggregate
               Group Key: t2.b
               ->  Nested Loop
                     ->  Nested Loop Left Join
                           ->  Result
                           ->  Seq Scan on t2 t2_1
                     ->  Materialize
                           ->  Seq Scan on t2
 Generated Plan Advice:
   JOIN_ORDER(t1 ("*RESULT*" t2#2 t2))
   NESTED_LOOP_PLAIN(t2#2)
   NESTED_LOOP_MATERIALIZE(t2)
   HASH_JOIN((t2 t2#2 "*RESULT*"))
   SEQ_SCAN(t1 t2#2 t2)
   SEMIJOIN_UNIQUE((t2 t2#2 "*RESULT*"))
   NO_GATHER(t1 t2 t2#2 "*RESULT*")
(20 rows)




-- 
Thanks,
Tender Wang





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-04-17 19:00  Robert Haas <[email protected]>
  parent: Tender Wang <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Robert Haas @ 2026-04-17 19:00 UTC (permalink / raw)
  To: Tender Wang <[email protected]>; +Cc: Alexander Lakhin <[email protected]>; Melanie Plageman <[email protected]>; Nathan Bossart <[email protected]>; Tom Lane <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>; heikki.linnakangas <[email protected]>

On Wed, Apr 15, 2026 at 9:45 PM Tender Wang <[email protected]> wrote:
> I try a quick fix as follow:

Thanks, but that's not quite correct: it filters out the unique relids
only after testing the list, and also copies the list an extra time
unnecessarily. I've pushed a fix that I believe to be correct, with a
test case.

-- 
Robert Haas
EDB: http://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 133+ messages in thread

* Re: pg_plan_advice
@ 2026-06-10 00:21  Michael Paquier <[email protected]>
  parent: Robert Haas <[email protected]>
  0 siblings, 0 replies; 133+ messages in thread

From: Michael Paquier @ 2026-06-10 00:21 UTC (permalink / raw)
  To: Robert Haas <[email protected]>; +Cc: Andrei Lepikhov <[email protected]>; Tom Lane <[email protected]>; Alexander Lakhin <[email protected]>; Lukas Fittl <[email protected]>; PostgreSQL Hackers <[email protected]>

On Mon, Apr 06, 2026 at 10:01:52AM -0400, Robert Haas wrote:
> On Mon, Apr 6, 2026 at 9:22 AM Andrei Lepikhov <[email protected]> wrote:
>> It would be better to introduce such a code at the beginning of the
>> development cycle, not right before the code freeze. At least we would
>> discuss its design without rushing.
>
> Yes, the timing is not ideal. However, I posted the patch on October
> 30th and committed the main patch on March 12th. I think that's a
> reasonable length of time to wait for people to provide feedback.

(Speaking with the pg_hint_plan kind-of-maintainer hat on.)
The timing is fine IMO.  In terms of integration with new APIs of
upstream, there is really nothing one can do until we are at least in
feature freeze.  Trying to work around APIs that have been committed
in the tree, which may be tuned after the initial commit, is just a
loss of time.  Things may get adjusted during beta, but the waves are
much weaker to deal with.

> During that time, the only person who provided information on how this
> will interact with out-of-core extensions was Lukas Fittl, who came to
> the conclusion that the pgs_mask infrastructure will be reusable by
> pg_hint_plan and will result in that module being simpler and
> involving less code duplication. Other extension authors could have
> provided feedback during that time as well, but none did, even after I
> posted to my blog to try to raise the visibility of this project. As
> far as I can tell, most extension developers don't pay much attention
> to core development until after we ship a beta. Had I waited until
> July to commit, I think there's a chance that it would have simply
> resulted in me getting whatever feedback I'm going to get next summer
> rather than this summer. At least this way, the issues will hopefully
> be fresh in my mind when the feedback arrives.

I have an answer to this one, in the shape of the following commits in
pg_hint_plan:
https://github.com/ossc-db/pg_hint_plan/commit/e42246a82589001de2f08255d3b4d984fb134d38
https://github.com/ossc-db/pg_hint_plan/commit/75b3d0142d2a8ea0e3d656e1c95ea3fdd6e8f082
https://github.com/ossc-db/pg_hint_plan/commit/5d386d3ecb832d3ea205d1e42e305cafefbefc76

The first commit is the most relevant one, and on a number basis I
finish with that, where I have been able to basically remove *all* the
historical hacks of the model in terms of plugs it added in the
planner:
 17 files changed, 1486 insertions(+), 3820 deletions(-)

At the end, I am particularly happy with the way things are regarding
the new join_path_setup and joinrel_setup hooks, that have removed
most of the bloat.

One thing that has caused me quite a bit of headache was parallel
hints.  At the end, I have followed Lukas suggestion to remove the old
path regeneration logic that was based on an enforcement of the GUCs
and switched to the PGS logic.  This is coming with some breakages in
the module, but these are actually super minor compared to the
accumulation of weird historical behavior that we had in it:
- When specifying only a JOIN hint (without leading), we now let the
planner decide the inner/outer order depending on the cost it sees,
not the order of the clauses.  That can always be enforced with a
Leading hint, which is the same thing as the JOIN_ORDER hint in
pg_plan_advice.
- Some slight changes in the way parallel hints are propagated to
child relations, due to build_simple_rel_hook().  We cannot really
avoid that, both behaviors are debatable, edge enough that I don't
worry much in terms of plan instabilities after a major release.  We
have some degree of that for each major release, users care *a lot*
about plan stability across minor releases, work around these after
major upgrades.

I strongly suspect that all these things are just going to be noise.
The regression test suite has basically no changes.

There are still gaps between pg_plan_advice and pg_hint_plan, and the
maintenance of the latter is now muuuuuch easier (still need to
maintain some versions for the stable branches).  The end game for me
would be to close the gap and merge both things together, then drop
pg_hint_plan.  I'll try to find some victi^D^D^D^D^D resources to do
some of the leg work to do the gap here (not planning to do that
myself), for some patches to-be-proposed in v20.  We have row hints,
parallel worker hints (aka RelOptInfo.rel_parallel_workers), memoize
hints, SET hints that could be added to contrib/pg_plan_advice.  There
are also hints that negate scan behaviors.  The negation hints are
not that popular, I think, but that may worth considering.

As of today on HEAD, 60%-ish of the remaining code relates to the
custom hint string parsing and feeding into the various hint
structures.  40%-ish of the code comes from the hooks and the internal
routines used by the hooks, for something like 5k lines of code.  This
is a difference between night and day.  (There may be more
simplifications doable in the code, planning an extra round of checks
during beta.)

In short, thanks for the work you have done in the v19 release cycle
in this area, Robert and others.
--
Michael


Attachments:

  [application/pgp-signature] signature.asc (833B, 2-signature.asc)
  download

^ permalink  raw  reply  [nested|flat] 133+ messages in thread


end of thread, other threads:[~2026-06-10 00:21 UTC | newest]

Thread overview: 133+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2025-10-30 14:00 pg_plan_advice Robert Haas <[email protected]>
2025-10-31 09:58 ` Jakub Wartak <[email protected]>
2025-10-31 12:51   ` Robert Haas <[email protected]>
2025-10-31 21:17     ` Alastair Turner <[email protected]>
2025-11-01 16:10       ` Hannu Krosing <[email protected]>
2025-11-03 16:41         ` Robert Haas <[email protected]>
2025-11-03 16:18       ` Robert Haas <[email protected]>
2025-11-04 19:54   ` Robert Haas <[email protected]>
2025-11-06 16:45     ` Robert Haas <[email protected]>
2025-11-17 14:42       ` Matheus Alcantara <[email protected]>
2025-11-17 15:09         ` Robert Haas <[email protected]>
2025-11-18 16:19           ` Robert Haas <[email protected]>
2025-11-23 00:43             ` Dian Fay <[email protected]>
2025-11-24 16:14               ` Robert Haas <[email protected]>
2025-11-30 03:16                 ` Dian Fay <[email protected]>
2025-12-05 19:57                   ` Robert Haas <[email protected]>
2025-12-08 20:39                     ` Greg Burd <[email protected]>
2025-12-09 19:34                       ` Robert Haas <[email protected]>
2025-12-09 01:18                     ` Jacob Champion <[email protected]>
2025-12-09 19:45                       ` Robert Haas <[email protected]>
2025-12-12 01:11                         ` Jacob Champion <[email protected]>
2025-12-12 17:36                           ` Robert Haas <[email protected]>
2025-12-12 18:09                             ` Jacob Champion <[email protected]>
2025-12-15 06:30                               ` Ajay Pal <[email protected]>
2025-12-15 16:37                               ` Jacob Champion <[email protected]>
2025-12-15 20:06                                 ` Robert Haas <[email protected]>
2025-12-17 10:12                                   ` Jakub Wartak <[email protected]>
2025-12-17 13:44                                     ` Jakub Wartak <[email protected]>
2025-12-18 12:27                                       ` Jakub Wartak <[email protected]>
2025-12-18 20:39                                       ` Robert Haas <[email protected]>
2025-12-18 13:36                                     ` Robert Haas <[email protected]>
2025-12-29 23:33                                   ` Haibo Yan <[email protected]>
2025-12-30 01:15                                   ` Lukas Fittl <[email protected]>
2026-03-02 04:10                                     ` David G. Johnston <[email protected]>
2026-03-02 22:09                                       ` David G. Johnston <[email protected]>
2026-03-02 23:11                                         ` David G. Johnston <[email protected]>
2026-03-04 15:17                                     ` Robert Haas <[email protected]>
2026-03-04 15:44                                       ` David G. Johnston <[email protected]>
2026-03-04 16:20                                         ` Robert Haas <[email protected]>
2026-03-06 14:46                                           ` David G. Johnston <[email protected]>
2026-03-10 14:55                                             ` Robert Haas <[email protected]>
2026-03-12 17:15                                               ` Robert Haas <[email protected]>
2026-03-12 23:45                                                 ` Alexandra Wang <[email protected]>
2026-03-16 17:34                                                   ` Robert Haas <[email protected]>
2026-03-13 08:38                                                 ` Lukas Fittl <[email protected]>
2026-03-16 20:51                                                   ` Robert Haas <[email protected]>
2026-03-16 23:25                                                     ` Lukas Fittl <[email protected]>
2026-03-17 13:44                                                       ` Robert Haas <[email protected]>
2026-03-14 12:00                                                 ` Alexander Lakhin <[email protected]>
2026-03-16 17:11                                                   ` Robert Haas <[email protected]>
2026-03-14 15:06                                                 ` Zsolt Parragi <[email protected]>
2026-03-16 16:52                                                   ` Robert Haas <[email protected]>
2026-03-17 05:06                                                     ` Zsolt Parragi <[email protected]>
2026-03-17 17:44                                                       ` Robert Haas <[email protected]>
2026-03-17 21:45                                                 ` Robert Haas <[email protected]>
2026-03-18 17:07                                                   ` Robert Haas <[email protected]>
2026-03-18 18:59                                                     ` Lukas Fittl <[email protected]>
2026-03-19 16:54                                                       ` Robert Haas <[email protected]>
2026-03-20 16:06                                                         ` Robert Haas <[email protected]>
2026-03-18 22:19                                                   ` Robert Haas <[email protected]>
2026-03-18 22:34                                                     ` Robert Haas <[email protected]>
2026-03-18 22:57                                                       ` Robert Haas <[email protected]>
2026-03-19 17:17                                                   ` Robert Haas <[email protected]>
2026-03-19 20:38                                                     ` Robert Haas <[email protected]>
2026-03-21 13:13                                                       ` Robert Haas <[email protected]>
2026-03-24 21:09                                                         ` Robert Haas <[email protected]>
2026-03-25 23:59                                                           ` Lukas Fittl <[email protected]>
2026-03-26 13:55                                                             ` Robert Haas <[email protected]>
2026-03-26 14:30                                                               ` Matheus Alcantara <[email protected]>
2026-03-26 14:37                                                                 ` Robert Haas <[email protected]>
2026-03-26 17:20                                                               ` Robert Haas <[email protected]>
2026-03-26 19:51                                                                 ` Lukas Fittl <[email protected]>
2026-03-26 23:25                                                                   ` Robert Haas <[email protected]>
2026-03-27 12:55                                                                     ` Robert Haas <[email protected]>
2026-04-02 23:43                                                                       ` Tom Lane <[email protected]>
2026-04-03 02:08                                                                         ` Robert Haas <[email protected]>
2026-04-03 17:13                                                                           ` Robert Haas <[email protected]>
2026-04-03 18:20                                                                             ` Tom Lane <[email protected]>
2026-04-04 00:14                                                                               ` Robert Haas <[email protected]>
2026-04-04 03:14                                                                                 ` Tom Lane <[email protected]>
2026-04-04 09:34                                                                                   ` Andrei Lepikhov <[email protected]>
2026-04-04 18:42                                                                                     ` Robert Haas <[email protected]>
2026-04-04 21:02                                                                                       ` Andrei Lepikhov <[email protected]>
2026-04-04 22:52                                                                                         ` Robert Haas <[email protected]>
2026-04-05 07:57                                                                                           ` Andrei Lepikhov <[email protected]>
2026-04-06 12:47                                                                                             ` Robert Haas <[email protected]>
2026-04-06 13:22                                                                                               ` Andrei Lepikhov <[email protected]>
2026-04-06 14:01                                                                                                 ` Robert Haas <[email protected]>
2026-06-10 00:21                                                                                                   ` Michael Paquier <[email protected]>
2026-04-05 08:00                                                                                           ` Alexander Lakhin <[email protected]>
2026-04-05 12:00                                                                                             ` Alexander Lakhin <[email protected]>
2026-04-06 20:15                                                                                               ` Robert Haas <[email protected]>
2026-04-06 19:52                                                                                             ` Robert Haas <[email protected]>
2026-04-06 13:56                                                                                         ` Andres Freund <[email protected]>
2026-04-06 14:14                                                                                           ` Peter Geoghegan <[email protected]>
2026-04-06 15:11                                                                                           ` Andrei Lepikhov <[email protected]>
2026-04-07 21:21                                                                                   ` Robert Haas <[email protected]>
2026-04-07 22:05                                                                                     ` Tom Lane <[email protected]>
2026-04-08 14:49                                                                                       ` Nathan Bossart <[email protected]>
2026-04-08 16:18                                                                                         ` Melanie Plageman <[email protected]>
2026-04-13 16:01                                                                                           ` Robert Haas <[email protected]>
2026-04-14 18:00                                                                                             ` Alexander Lakhin <[email protected]>
2026-04-15 10:30                                                                                               ` Tender Wang <[email protected]>
2026-04-15 19:46                                                                                                 ` Robert Haas <[email protected]>
2026-04-16 01:45                                                                                                   ` Tender Wang <[email protected]>
2026-04-17 19:00                                                                                                     ` Robert Haas <[email protected]>
2026-03-26 23:20                                                                 ` Mark Dilger <[email protected]>
2026-03-27 14:20                                                                   ` Robert Haas <[email protected]>
2026-03-27 08:00                                                                 ` Jakub Wartak <[email protected]>
2026-03-27 08:31                                                                   ` Lukas Fittl <[email protected]>
2026-03-27 15:44                                                                     ` Robert Haas <[email protected]>
2026-03-29 18:58                                                                       ` Lukas Fittl <[email protected]>
2026-03-30 14:53                                                                         ` Robert Haas <[email protected]>
2026-04-01 02:25                                                                           ` Lukas Fittl <[email protected]>
2026-04-01 06:33                                                                             ` Lukas Fittl <[email protected]>
2026-04-02 16:44                                                                               ` Robert Haas <[email protected]>
2026-04-02 16:15                                                                             ` Robert Haas <[email protected]>
2026-04-03 02:15                                                                               ` Robert Haas <[email protected]>
2026-04-04 08:11                                                                                 ` Lukas Fittl <[email protected]>
2026-04-07 14:17                                                                                   ` Robert Haas <[email protected]>
2026-03-27 13:08                                                                   ` Robert Haas <[email protected]>
2026-03-24 20:47                                                       ` Robert Haas <[email protected]>
2026-03-19 21:33                                                     ` Robert Haas <[email protected]>
2026-03-19 22:19                                                       ` Robert Haas <[email protected]>
2026-03-19 23:11                                                         ` Robert Haas <[email protected]>
2025-12-10 11:43                     ` Jakub Wartak <[email protected]>
2025-12-10 13:33                       ` Robert Haas <[email protected]>
2025-12-11 15:09                     ` Robert Haas <[email protected]>
2025-11-04 11:47 ` John Naylor <[email protected]>
2025-12-10 11:20 ` Amit Langote <[email protected]>
2025-12-10 14:54   ` Robert Haas <[email protected]>
2025-12-10 21:09     ` Corey Huinker <[email protected]>
2025-12-10 21:29       ` Robert Haas <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox