Subject: Re: pg_plan_advice

From: Lukas Fittl <[email-masked]>
Date: Sun, 11 Jan 2026 12:20:32 -0800

On Tue, Jan 6, 2026 at 11:36 AM Robert Haas <[email-masked]> wrote:
>
> On Mon, Dec 29, 2025 at 8:15 PM Lukas Fittl <[email-masked]> wrote:
> > For 0001, I'm not sure the following comment is correct:
> >
> > > /* When recursing = true, it's an unplanned or dummy subquery. */
> > > rtinfo->dummy = recursing;
> >
> > Later in that function we only recurse if its a dummy subquery - in the case of an unplanned subquery (rel->subroot == 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 = true, it's a dummy subquery or its children.".
>
> Presumably, a child of an unplanned or dummy subquery will also be
> unplanned or dummy, so I'm not sure I understand the need to clarify
> here.

I think I was more trying to argue that unplanned subqueries are not
actually being considered here, since the recursing flag will never be
true for an unplanned subquery. The "or its children" part was more to
capture my understanding, and seems fine to omit too.

> > I also noticed that this currently doesn't support cases where multiple nodes are elided, e.g. with multi-level table partitioning:
>
> ...
> I'm not really sure there's a problem here. We definitely do not want
> to end up with something like "Elided Node RTIs: 1 2". What I've found
> experimentally is that it's often important to preserve relid sets,
> but you need to preserve them as sets, not individually. I hesitate a little bit
> to design something without a use case in mind, but maybe you have
> one?

It just seemed inconsistent to me, but I think I follow your argument
as to why just adding it to the set isn't correct. I don't have a
particular use case beyond advice/hint application in mind, so if this
works in your assessment and is not an oversight, that sounds good to
me.

>
> > For 0003:
> >
> > I also find the "cars" variable suffix a bit hard to understand, but not sure a comment next to the variables is that useful.
> > Separately, the noise generated by all the additional "_cars" variables isn't great.
> >
> > I wonder a little bit if we couldn't introduce a better abstraction here, e.g. a struct "AppendPathInput" that contains the
> > two related lists, and gets populated by accumulate_append_subpath/get_singleton_append_subpath and then
> > passed to create_append_path as a single argument.
>
> I spent some time thinking about this day and haven't been quite able
> to come up with something that I like. The problem is that
> pa_partial_subpaths and pa_nonpartial_subpaths share a single
> child_append_relid_sets variable, namely pa_subpath_cars, and
> accumulate_append_subpaths gets called with that as the last argument
> and different things for the previous two. One thing I tried was
> making the AppendPathInput struct contain three lists rather than two,
> but then accumulate_append_subpath() needs an argument that makes it
> work in one of three different modes:
>
> Mode 1: normal -- add everything to the "normal" list
> Mode 2: building parallel-aware append with partial path -- add things
> to the "normal" list except for parallel-aware appends which need to
> be split between the normal and special lists
> Mode 3: building parallel-aware append with non-partial path -- add
> things to the "special" list
>

Yeah, the difference in these modes makes this a bit challenging.

I wonder a bit if we shouldn't instead focus on this being about the
inputs to create_append_path (and the 4 different variants of calling
it in add_paths_to_append_rel), and make sure we group some of them
together in a struct, but still pass the individual fields of that
struct to accumulate_append_subpaths.

I've sketched out what I mean in the attached (once as a patch on top
of v8, and then again as a separate patch that's combined with
v8/0003). That makes add_paths_to_append_rel easier to understand (to
me at least), at a slight increase in complexity in cases where we
call create_append_path without passing child_append_relid_sets or
partial subpaths.

Thanks,
Lukas

-- 
Lukas Fittl

---

From: Robert Haas <[email-masked]>
Date: Mon, 12 Jan 2026 12:13:32 -0500

On Sun, Jan 11, 2026 at 3:21 PM Lukas Fittl <[email-masked]> wrote:
> I've sketched out what I mean in the attached

This is a good idea, IMHO. It didn't compile and I made some other
cosmetic fixes, but I like the direction.

Here's v9. Changes:

* Updated all patches with reviewers to date and discussion link. I
have listed the same reviewers for all of 0001-0004, but the list for
0005 is different, based on my reading of the emails on this thread.
If someone feels that I've made the wrong choices about listing them,
please let me know.
* Incorporated Lukas's idea into 0003 and listed him as a co-author.
* Some comment improvements to 0004.

In 0005:

* Disallow JOIN_ORDER() without targets, per feedback from Ajay Pal.
* pgindent
* Incorporate the fix to pgpa_process_unrolled_join() that I
previously posted separately, and update the associated comments.

I now feel that I've had enough substantial feedback on 0003 and 0004
that it wouldn't be crazy to think about committing those, unless
further review turns up problems or something. I think I need more
substantial review on 0001 and 0002 before I could consider moving
forward with those. And 0005 needs a lot more review, and also a lot
more work from my side. Given that, where do I go from here? I don't
think it makes sense to commit 0002 or 0003 without 0001, but 0004 is
mostly independent of 0001-0003. So, I think that if more feedback on
0001 and 0002 is forthcoming soon, I'll probably just wait a bit and
hope to commit 0001-0004 in the current order. If not, I'll rearrange
the series to move 0004 to the front, and plan to commit that first.

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

---

From: John Naylor <[email-masked]>
Date: Tue, 13 Jan 2026 18:38:51 +0700

> Here's v9. Changes:

This is perhaps the least interesting part of 0005, but since I
committed this API, I thought I'd chime in:

+ /* 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);

It looks like it would be helpful if fasthash_accum_cstring just
returned zero when given a NULL string, as in the attached. We could
also do something like add a large number to the hash, but I'm not
sure that's necessary.

+ /*
+ * 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);

Sounds reasonable, so the patch also documents that.

--
John Naylor
Amazon Web Services

---

From: Robert Haas <[email-masked]>
Date: Tue, 13 Jan 2026 10:09:14 -0500

> also do something like add a large number to the hash, but I'm not

I think that pg_plan_advice's requirement are unusual here, so I would
suggest not adding a branch to fasthash_accum_cstring. If this were a
requirement that almost every caller had, then it would make sense to
pay the cost in the common function. But there will probably be a
small performance cost to this, hash function are often called in hot
paths, and pg_plan_advice is, I think, unusual, so I don't really like
doing it given thoe facts.

> Sounds reasonable, so the patch also documents that.

Some kind of comment change here seems useful to me. I wonder whether
it should be generalized even more than this statement. I also wonder
if this is really the optimal strategy. But I definitely agree that
clarifying this in whatever way makes sense is a good idea.

---

From: Jakub Wartak <[email-masked]>
Date: Wed, 14 Jan 2026 12:02:11 +0100

On Mon, Jan 12, 2026 at 6:13 PM Robert Haas <[email-masked]> wrote:
[..]
> Here's v9. Changes:

OK, so I was thinking v9 is going to be pretty slick ride, however got
some issues inside 0005 :

1) with cassert/debug builds (meson setup build --prefix=/usr/pgsql19
--buildtype=debug -Dcassert=true) I've started getting nonalways
non-deterministic failures for TPC-H Q4 and Q8. That was a somewhat
self-dissolving error (happened fresh after data load when the testing
suite was launched rapidly afterwards), so I've tracked it down to the
autoanalyze gathering stats. So if load the the whole suite of data,
do not run analyze and stay with autovacuum=off (to avoid autoanalyze)
and run the testing query suite, it identified this failure to force
NL instead of HJ in q8 but also *uncovered* runtime error in q4 that
happens with no stats

a) q4.sql (please see attached file for repro). More or less: right
after import I get a hard failure if the earlier recommended advice is
enabled (smells like a bug to me: we shouldn't get any errors even if
advice is bad). This can be solved by ANALYZE, but brought up back by
truncating pg_statistics
ERROR:  unique semijoin found for relids (b 3) but not observed during planning
STATEMENT:  explain (timing off, costs off, settings off, memory off)

a) q8.sql (please see attached file for demo). It is even more
bizarre, happens right after import , fixed by ANALYZE, but even
TRUNCATING pg_statistic doesnt bring back the problem. Pinpointed that
additional pg_clear_relation_stats() triggers the problem back.

2) Somewhat in default buildtype debugoptimized (plain "meson setup
build --prefix=/usr/pgsql19") I'm getting crashes with v9 in
contrib/pg_plan_advice/pgpa_planner.c:pgpa_join_path_setup line 460,
full stack trace attached.

2026-01-14 10:54:04.718 CET [97138] LOG:  client backend (PID 97408)
was terminated by signal 11: Segmentation fault
2026-01-14 10:54:04.718 CET [97138] DETAIL:  Failed process was
running: explain (timing off, costs off, settings off, memory off)
        SELECT
            s_name,
            s_address
        [..]
            AND s_nationkey = n_nationkey
            AND n_name = 'CANADA'
        ORDER BY
            s_name;
2026-01-14 10:54:04.718 CET [97138] LOG:  terminating any other active
server processes

To me it looks like "pps" is NULL and hits "if
(pps->generate_advice_string)" because
GetPlannerGlobalExtensionState() returns NULL because
root->glob->extension_state_allocated is 0 (while planner_extension_id
is also 0). Crash is only happening for q20 and q4, till I've tried
the below fixup which seems to solve it (?) - it's just based on the
fact that all other uses of GetPlannerGlobalExtensionState() seem to
check for NULL:

-               if (pps->generate_advice_string)
+               if (pps != NULL && pps->generate_advice_string)

3) Also so I went ahead runnning the full suite (without and with
ANALYZE statistics) with asan, so with CFLAGS="-O2 -g -ggdb
-fno-sanitize-recover=all -fsanitize=address" and
ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:print_stacktrace=1:disable_coredump=0:strict_string_checks=1:check_initialization_order=1:strict_init_order=1:detect_stack_use_after_return=0
(the last option seem to be critical to avoid hitting max_stack_depth
issues on my gcc - XXX marker here). So it did catch previous issue,
e.g:

[..all queries running fine..]
q19.sql
q1.sql
q20.sql
AddressSanitizer:DEADLYSIGNAL
=================================================================
==153072==ERROR: AddressSanitizer: SEGV on unknown address
0x000000000008 (pc 0x7de84f7822cc bp 0x7ffda327e420 sp 0x7ffda327e130
T0)
==153072==The signal is caused by a READ memory access.
==153072==Hint: address points to the zero page.
    #0 0x7de84f7822cc in pgpa_join_path_setup
../contrib/pg_plan_advice/pgpa_planner.c:460
    #1 0x64bff4d5966d in add_paths_to_joinrel
../src/backend/optimizer/path/joinpath.c:180
    #2 0x64bff4d60a8a in populate_joinrel_with_paths
../src/backend/optimizer/path/joinrels.c:1197
    #3 0x64bff4d63377 in make_join_rel
../src/backend/optimizer/path/joinrels.c:774
[..]

but with the above fixup all seems to clean in multiple scenarios
(stats, no stats) and including basic "ninja test".

3b) XXX - marker:I was looking for a solution and apparently cfbot
farm has those options, so they should be testing it anyway. And this
brings me to a fact, that it maybe could be detected by cfbot, however
the $thread is not registered so cfbot had no chance to see what's
more there? (I'm mainly thinking about any cross-platform issues, if
any).

-J.
-- q8 failure summary:

        JOIN_ORDER(n1 region customer orders lineitem part supplier n2) /* matched, conflicting, failed */
        NESTED_LOOP_PLAIN(part) /* matched, conflicting, failed */
        NESTED_LOOP_PLAIN(supplier) /* matched, conflicting, failed */
--- /tmp/plan   2026-01-14 08:03:36.360974545 +0100
+++ /tmp/planadviced    2026-01-14 08:03:36.362974547 +0100
@@ -3,8 +3,10 @@
   ->  Sort
         Sort Key: (EXTRACT(year FROM orders.o_orderdate))
         ->  Nested Loop
-              ->  Nested Loop
-                    ->  Nested Loop
+              ->  Hash Join
+                    Hash Cond: (lineitem.l_partkey = part.p_partkey)
+                    ->  Hash Join
+                          Hash Cond: (lineitem.l_suppkey = supplier.s_suppkey)
                           ->  Nested Loop
                                 ->  Nested Loop
                                       ->  Nested Loop
@@ -23,10 +25,10 @@
                                             Filter: ((o_orderdate >= '1995-01-01'::date) AND (o_orderdate <= '1996-12-31'::date))
                                 ->  Index Scan using pk_lineitem on lineitem
                                       Index Cond: (l_orderkey = orders.o_orderkey)
+                          ->  Hash
+                                ->  Index Scan using pk_supplier on supplier
+                    ->  Hash
                           ->  Index Scan using pk_part on part
-                                Index Cond: (p_partkey = lineitem.l_partkey)
                                 Filter: ((p_type)::text = 'ECONOMY ANODIZED STEEL'::text)
-                    ->  Index Scan using pk_supplier on supplier
-                          Index Cond: (s_suppkey = lineitem.l_suppkey)
               ->  Index Scan using pk_nation on nation n2
                     Index Cond: (n_nationkey = supplier.s_nationkey)

--
-- manual repro details:
--
dbt3=# explain (costs off, plan_advice) SELECT
    o_year,
    sum(CASE
            WHEN nation = 'BRAZIL'
            THEN volume
            ELSE 0
        END) / sum(volume) AS mkt_share
FROM (
    SELECT
        extract(year FROM o_orderdate) AS o_year,
        l_extendedprice * (1 - l_discount) AS volume,
        n2.n_name AS nation
    FROM
        part,
        supplier,
        lineitem,
        orders,
        customer,
        nation n1,
        nation n2,
        region
    WHERE
        p_partkey = l_partkey
        AND s_suppkey = l_suppkey
        AND l_orderkey = o_orderkey
        AND o_custkey = c_custkey
        AND c_nationkey = n1.n_nationkey
        AND n1.n_regionkey = r_regionkey
        AND r_name = 'AMERICA'
        AND s_nationkey = n2.n_nationkey
        AND o_orderdate BETWEEN DATE '1995-01-01' AND DATE '1996-12-31'
        AND p_type = 'ECONOMY ANODIZED STEEL'
    ) AS all_nations
GROUP BY
    o_year
ORDER BY
    o_year;
                                                            QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate
   Group Key: (EXTRACT(year FROM orders.o_orderdate))
   ->  Sort
         Sort Key: (EXTRACT(year FROM orders.o_orderdate))
         ->  Nested Loop
               ->  Nested Loop
                     ->  Nested Loop
                           ->  Nested Loop
                                 ->  Nested Loop
                                       ->  Nested Loop
                                             ->  Hash Join
                                                   Hash Cond: (n1.n_regionkey = region.r_regionkey)
                                                   ->  Seq Scan on nation n1
                                                   ->  Hash
                                                         ->  Seq Scan on region
                                                               Filter: (r_name = 'AMERICA'::bpchar)
                                             ->  Bitmap Heap Scan on customer
                                                   Recheck Cond: (c_nationkey = n1.n_nationkey)
                                                   ->  Bitmap Index Scan on customer_c_nationkey_c_custkey_idx
                                                         Index Cond: (c_nationkey = n1.n_nationkey)
                                       ->  Index Scan using orders_o_custkey_idx on orders
                                             Index Cond: (o_custkey = customer.c_custkey)
                                             Filter: ((o_orderdate >= '1995-01-01'::date) AND (o_orderdate <= '1996-12-31'::date))
                                 ->  Index Scan using pk_lineitem on lineitem
                                       Index Cond: (l_orderkey = orders.o_orderkey)
                           ->  Index Scan using pk_part on part
                                 Index Cond: (p_partkey = lineitem.l_partkey)
                                 Filter: ((p_type)::text = 'ECONOMY ANODIZED STEEL'::text)
                     ->  Index Scan using pk_supplier on supplier
                           Index Cond: (s_suppkey = lineitem.l_suppkey)
               ->  Index Scan using pk_nation on nation n2
                     Index Cond: (n_nationkey = supplier.s_nationkey)
 Generated Plan Advice:
   JOIN_ORDER(n1 region customer orders lineitem part supplier n2)
   NESTED_LOOP_PLAIN(customer orders lineitem part supplier n2)
   HASH_JOIN(region)
   SEQ_SCAN(n1 region)
   BITMAP_HEAP_SCAN(customer public.customer_c_nationkey_c_custkey_idx)
   INDEX_SCAN(orders public.orders_o_custkey_idx lineitem public.pk_lineitem
    part public.pk_part supplier public.pk_supplier n2 public.pk_nation)
   NO_GATHER(part supplier lineitem orders customer n1 n2 region)

-- copy paste because why not?
dbt3=# set pg_plan_advice.advice = 'JOIN_ORDER(n1 region customer orders lineitem part supplier n2)
   NESTED_LOOP_PLAIN(customer orders lineitem part supplier n2)
   HASH_JOIN(region)
   SEQ_SCAN(n1 region)
   BITMAP_HEAP_SCAN(customer public.customer_c_nationkey_c_custkey_idx)
   INDEX_SCAN(orders public.orders_o_custkey_idx lineitem public.pk_lineitem
    part public.pk_part supplier public.pk_supplier n2 public.pk_nation)
   NO_GATHER(part supplier lineitem orders customer n1 n2 region)';
SET

--
-- 3x failures while adapting from advice, 2x for NESTED_LOOP_PLAIN
-- causing HJ instead of NL
--
dbt3=# explain (costs off) SELECT
    o_year,
    sum(CASE
            WHEN nation = 'BRAZIL'
            THEN volume
            ELSE 0
        END) / sum(volume) AS mkt_share
FROM (
    SELECT
        extract(year FROM o_orderdate) AS o_year,
        l_extendedprice * (1 - l_discount) AS volume,
        n2.n_name AS nation
    FROM
        part,
        supplier,
        lineitem,
        orders,
        customer,
        nation n1,
        nation n2,
        region
    WHERE
        p_partkey = l_partkey
        AND s_suppkey = l_suppkey
        AND l_orderkey = o_orderkey
        AND o_custkey = c_custkey
        AND c_nationkey = n1.n_nationkey
        AND n1.n_regionkey = r_regionkey
        AND r_name = 'AMERICA'
        AND s_nationkey = n2.n_nationkey
        AND o_orderdate BETWEEN DATE '1995-01-01' AND DATE '1996-12-31'
        AND p_type = 'ECONOMY ANODIZED STEEL'
    ) AS all_nations
GROUP BY
    o_year
ORDER BY
    o_year;
                                                            QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate
   Group Key: (EXTRACT(year FROM orders.o_orderdate))
   ->  Sort
         Sort Key: (EXTRACT(year FROM orders.o_orderdate))
         ->  Nested Loop
               ->  Hash Join
                     Hash Cond: (lineitem.l_partkey = part.p_partkey)
                     ->  Hash Join
                           Hash Cond: (lineitem.l_suppkey = supplier.s_suppkey)
                           ->  Nested Loop
                                 ->  Nested Loop
                                       ->  Nested Loop
                                             ->  Hash Join
                                                   Hash Cond: (n1.n_regionkey = region.r_regionkey)
                                                   ->  Seq Scan on nation n1
                                                   ->  Hash
                                                         ->  Seq Scan on region
                                                               Filter: (r_name = 'AMERICA'::bpchar)
                                             ->  Bitmap Heap Scan on customer
                                                   Recheck Cond: (c_nationkey = n1.n_nationkey)
                                                   ->  Bitmap Index Scan on customer_c_nationkey_c_custkey_idx
                                                         Index Cond: (c_nationkey = n1.n_nationkey)
                                       ->  Index Scan using orders_o_custkey_idx on orders
                                             Index Cond: (o_custkey = customer.c_custkey)
                                             Filter: ((o_orderdate >= '1995-01-01'::date) AND (o_orderdate <= '1996-12-31'::date))
                                 ->  Index Scan using pk_lineitem on lineitem
                                       Index Cond: (l_orderkey = orders.o_orderkey)
                           ->  Hash
                                 ->  Index Scan using pk_supplier on supplier
                     ->  Hash
                           ->  Index Scan using pk_part on part
                                 Filter: ((p_type)::text = 'ECONOMY ANODIZED STEEL'::text)
               ->  Index Scan using pk_nation on nation n2
                     Index Cond: (n_nationkey = supplier.s_nationkey)
 Supplied Plan Advice:
   SEQ_SCAN(n1) /* matched */
   SEQ_SCAN(region) /* matched */
   BITMAP_HEAP_SCAN(customer public.customer_c_nationkey_c_custkey_idx) /* matched */
   INDEX_SCAN(orders public.orders_o_custkey_idx) /* matched */
   INDEX_SCAN(lineitem public.pk_lineitem) /* matched */
   INDEX_SCAN(part public.pk_part) /* matched */
   INDEX_SCAN(supplier public.pk_supplier) /* matched */
   INDEX_SCAN(n2 public.pk_nation) /* matched */
   JOIN_ORDER(n1 region customer orders lineitem part supplier n2) /* matched, conflicting, failed */
   NESTED_LOOP_PLAIN(customer) /* matched, conflicting */
   NESTED_LOOP_PLAIN(orders) /* matched, conflicting */
   NESTED_LOOP_PLAIN(lineitem) /* matched, conflicting */
   NESTED_LOOP_PLAIN(part) /* matched, conflicting, failed */
   NESTED_LOOP_PLAIN(supplier) /* matched, conflicting, failed */
   NESTED_LOOP_PLAIN(n2) /* matched, conflicting */
   HASH_JOIN(region) /* matched, conflicting */
   NO_GATHER(part) /* matched */
   NO_GATHER(supplier) /* matched */
   NO_GATHER(lineitem) /* matched */
   NO_GATHER(orders) /* matched */
   NO_GATHER(customer) /* matched */
   NO_GATHER(n1) /* matched */
   NO_GATHER(n2) /* matched */
   NO_GATHER(region) /* matched */
(59 rows)

-- of course after manual stats gathering the advices are different:
--

dbt3=# analyze ;
ANALYZE
dbt3=# explain (costs off, plan_advice) SELECT
[..]
 Generated Plan Advice:
   JOIN_ORDER(n1 region customer orders lineitem supplier part n2)
   NESTED_LOOP_PLAIN(customer orders lineitem n2)
   HASH_JOIN(region supplier part)
   SEQ_SCAN(n1 region)
   BITMAP_HEAP_SCAN(customer public.customer_c_nationkey_c_custkey_idx)
   INDEX_SCAN(orders public.orders_o_custkey_idx lineitem public.pk_lineitem
    supplier public.pk_supplier part public.pk_part n2 public.pk_nation)
   NO_GATHER(part supplier lineitem orders customer n1 n2 region)
(68 rows)

-- diff of advices:
----- JOIN_ORDER is the same (just different sort),
----- of course more in HJ less in NL advice, so the question is still why we couldn't use the plan we generated initially
--- /tmp/no-stats       2026-01-14 10:39:27.697249882 +0100
+++ /tmp/after-stats    2026-01-14 10:39:16.224234561 +0100
@@ -1,9 +1,9 @@
  Generated Plan Advice:
-   JOIN_ORDER(n1 region customer orders lineitem part supplier n2)
-   NESTED_LOOP_PLAIN(customer orders lineitem part supplier n2)
-   HASH_JOIN(region)
+   JOIN_ORDER(n1 region customer orders lineitem supplier part n2)
+   NESTED_LOOP_PLAIN(customer orders lineitem n2)
+   HASH_JOIN(region supplier part)
    SEQ_SCAN(n1 region)
    BITMAP_HEAP_SCAN(customer public.customer_c_nationkey_c_custkey_idx)
    INDEX_SCAN(orders public.orders_o_custkey_idx lineitem public.pk_lineitem
-    part public.pk_part supplier public.pk_supplier n2 public.pk_nation)
+    supplier public.pk_supplier part public.pk_part n2 public.pk_nation)
    NO_GATHER(part supplier lineitem orders customer n1 n2 region)

-- so even with TRUNCATE pg_statistics the advice is different than the initial one ()
-- so what makes it generate so wildly different plan advice if in theory both situation is the same?

--- /tmp/no-stats       2026-01-14 10:39:27.697249882 +0100
+++ /tmp/after-stats-after-trunc-pg_stats       2026-01-14 10:43:33.864576258 +0100
@@ -1,9 +1,9 @@
  Generated Plan Advice:
-   JOIN_ORDER(n1 region customer orders lineitem part supplier n2)
-   NESTED_LOOP_PLAIN(customer orders lineitem part supplier n2)
-   HASH_JOIN(region)
-   SEQ_SCAN(n1 region)
+   JOIN_ORDER(orders (n1 region customer) lineitem supplier n2 part)
+   NESTED_LOOP_PLAIN(customer lineitem supplier part)
+   HASH_JOIN(region (customer n1 region) n2)
+   SEQ_SCAN(orders n1 region n2)
    BITMAP_HEAP_SCAN(customer public.customer_c_nationkey_c_custkey_idx)
-   INDEX_SCAN(orders public.orders_o_custkey_idx lineitem public.pk_lineitem
-    part public.pk_part supplier public.pk_supplier n2 public.pk_nation)
-   NO_GATHER(part supplier lineitem orders customer n1 n2 region)
+   INDEX_SCAN(lineitem public.pk_lineitem supplier public.pk_supplier part
+    public.pk_part)
+   GATHER((part supplier lineitem orders customer n1 n2 region))

-- so if press harder and do:
--    1. ANALYZE; -- makes problem go away
--    2. TRUNCATE pg_statisitc + reconnect -- problem still not reproducible
--    3. (new)select pg_clear_relation_stats('public', relname) from pg_class where relnamespace = 2200 and relkind = 'r';
-- the problem is AGAIN reproducible

---

From: Robert Haas <[email-masked]>
Date: Wed, 14 Jan 2026 17:11:11 -0500

> STATEMENT:  explain (timing off, costs off, settings off, memory off)

Hmm, so the plan tree walker thinks that we did a semijoin between
lineitem and orders by making lineitem unique on the join column and
then performing a regular join. That appears to be correct. But
pgpa_join_path_setup never created a pgpa_join_path_setup for that
possibility, or created one that doesn't actually match up properly to
what was found in the plan tree. Can you check whether a
pgpa_sj_unique_rel gets created in pgpa_join_path_setup, and with what
contents?

> additional pg_clear_relation_stats() triggers the problem back.

I found this one. I now think that
pgpa_planner_apply_join_path_advice() shouldn't added anything to
jo_permit_indexes when a join method hint implicitly permits a join
order. I simplified your test case to this:

set pg_plan_advice.advice = 'JOIN_ORDER(n1 region customer)
NESTED_LOOP_PLAIN(region)';
explain (costs off, plan_advice)
    SELECT
        n1.n_name AS nation
    FROM
        customer,
        nation n1,
        region
    WHERE
        c_nationkey = n1.n_nationkey
        AND n1.n_regionkey = r_regionkey
        AND r_name = 'AMERICA';

What was happening here is that when we considered a join between
{customer, nation} and region, pgpa_planner_apply_join_path_advice()
said, well, according to the JOIN_ORDER advice, this join order is not
allowed, which is correct. And, according to the NESTED_LOOP_PLAIN
advice, this join order is allowed, which is also correct, because
NESTED_LOOP_PLAIN(region) denies join orders where region is the
driving table, since those would make it impossible to respect the
advice, and this join order doesn't do that. Then, it concludes that
because one piece of advice says the join order is OK and the other
says it isn't, the advice conflicts. This is where I think it's going
off the rails: the NESTED_LOOP_PLAIN() advice should only be allowed
to act as a negative constraint, not a positive one. So what I did is:

diff --git a/contrib/pg_plan_advice/pgpa_planner.c
b/contrib/pg_plan_advice/pgpa_planner.c
index 13f81e9b063..95dd71deb84 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -982,7 +982,6 @@ pgpa_planner_apply_join_path_advice(JoinType
jointype, uint64 *pgs_mask_p,
                 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(jm_indexes, i);
                 if (join_mask != 0 && join_mask != my_join_mask)
                     jm_conflict = true;
@@ -1038,8 +1037,6 @@ pgpa_planner_apply_join_path_advice(JoinType
jointype, uint64 *pgs_mask_p,
                 }
                 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 (pps != NULL && pps->generate_advice_string)

Agreed, will incorporate that fix.

> more there? (I'm mainly thinking about any cross-platform issues, if

I mean, there is https://commitfest.postgresql.org/patch/6184/

---

From: Jakub Wartak <[email-masked]>
Date: Thu, 15 Jan 2026 13:28:30 +0100

On Wed, Jan 14, 2026 at 11:11 PM Robert Haas <[email-masked]> wrote:

Hi Robert,

> pgpa_sj_unique_rel gets created in pgpa_join_path_setup, and with what

OK, so today, on just barebone v9 (even without any fixes from this $subthread),
I couldn't get it to reproduce right out of the box right on the fresh
cluster. It
appears to another missing piece of the puzzle was to have
max_parallel_workers_per_gather=0  (in addition to TRUNCATING pg_statistic),
because otherwise it did not want to generate advice out of the box that would
generate this specific ERROR.

Maybe I'm big rookie here (OR just dumb), but it took me some time to realize
why we emit SEMIJOIN_UNIQUE() there, clearly the plan without parallelism
has "Nested Loop", not like "Nested Loop Semi Join"
(with max_parallel_workers_per_gather = 2). Yet it emits that and somehow
later the query feature walker->query_features[PGPAQF_SEMIJOIN_UNIQUE]
also is there, so that "unique semijoin found for.." error could be thrown.

As per VERBOSE explain, one can spot this SemiJoin transformation is being
applied (Nested Loop/Inner Unique: true), however sj_unique_rtis is empty (?!):

[..]
NOTICE:  jointype=outer pps_NULL?=0
NOTICE:  added SEMIJOIN_UNIQUE
NOTICE:  pgpa_plan_walker: walking over SEMIJOIN_UNIQUE features: 3,
sj_unique_rtis=<> sj_unique_rels=<>
ERROR:  unique semijoin found for relids (b 3) but not observed during planning

Only *after* this, I've realized how hard all of that is reading comments nearby
`typedef struct pgpa_sj_unique_rel`.

Anyway it appears that pgpa_plan_walker()/pgpa_planner_walker() is not
having proper
input information to begin with about SJs? It looks there is just one
single place that
sets pps->sj_unique_rels (lappend() in pgpa_join_path_setup()), but
that's code path is
only being launched when requesting explain is asking for advice:

explain (costs off, plan_advice) SELECT
[..]
NOTICE:  jointype=inner pps_NULL?=0
NOTICE:  found=0
NOTICE:  not a duplicate, appending "(b 3)" to pps->sj_unique_rels
NOTICE:  jointype=outer pps_NULL?=0
NOTICE:  found=true! (ur->plan_name=(null) bms_ur_relids=3)
NOTICE:  found=1
NOTICE:  jointype=inner pps_NULL?=0
NOTICE:  found=true! (ur->plan_name=(null) bms_ur_relids=3)
NOTICE:  found=1
NOTICE:  jointype=outer pps_NULL?=0
NOTICE:  found=true! (ur->plan_name=(null) bms_ur_relids=3)
NOTICE:  found=1
NOTICE:  added SEMIJOIN_UNIQUE
WARNING:  could not dump unrecognized node type: 0 // ignore?
NOTICE:  pgpa_plan_walker: walking over SEMIJOIN_UNIQUE features: 3,
sj_unique_rtis=((b 3)) sj_unique_rels=({})
(+ no error!)

while with basic EXPLAIN (and advices planner/advises touching SJ
transforms), I'm getting:

dbt3=# explain (costs off) SELECT
[..]
NOTICE:  jointype=inner pps_NULL?=0
NOTICE:  jointype=outer pps_NULL?=0
NOTICE:  jointype=inner pps_NULL?=0
NOTICE:  jointype=outer pps_NULL?=0
NOTICE:  added SEMIJOIN_UNIQUE
NOTICE:  pgpa_plan_walker: walking over SEMIJOIN_UNIQUE features: 3,
sj_unique_rtis=<> sj_unique_rels=<>
ERROR:  unique semijoin found for relids (b 3) but not observed during planning

So we have started v9 with:
  if (pps->generate_advice_string) { -- but that's wrong due to
potential crash in -02 builds + asan complaints

we fixed that above bug with:
  if (pps != NULL && pps->generate_advice_string) {  -- but that's
wrong due to not initializing SJ for normal explains

so we end up doing simply this?
  if (pps != NULL) {

The last one seems to pass all my tests (with already provided fixup
from yesterday), but I'm absolutely not sure if that's the proper way to
address that).

> to act as a negative constraint, not a positive one. So what I did is:

Yes! 3 three lines patches seems to help (and causes no other problems to
best of my knowledge). The simplified test case results seem to be only
changing like below, but it really fixes the Q8 NL->HJ.

  Supplied Plan Advice:
-   JOIN_ORDER(n1 region customer) /* matched, conflicting */
-   NESTED_LOOP_PLAIN(region) /* matched, conflicting */
+   JOIN_ORDER(n1 region customer) /* matched */
+   NESTED_LOOP_PLAIN(region) /* matched */

BTW: I have found also that it fixes another (not yet here disclosed bug,
because I've found it just today :): when running without stats, and
with enable_nestloop=OFF (globally) Q5 was failing too due some
sequence of HJ/Parallel HJ
and slightly different Hash Cond) - nvm, wIth this patch it does NOT misbehave.

Maybe it would be good to include that into tests inside 0005, for that
small tiny query above?

> I mean, there is https://commitfest.postgresql.org/patch/6184/

Whoops, mea culpa, I was looking for PG-4 commitfest for some reason
(so I should
be looking on https://cfbot.cputube.org/next.html not just under "/"
[main] one).

-J.
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
index fdc53da9742..a5b784fd151 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -457,7 +457,11 @@ pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
 
 		uniquerel = jointype == JOIN_UNIQUE_OUTER ? outerrel : innerrel;
 		pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
-		if (pps->generate_advice_string)
+		elog(NOTICE, "jointype=%s pps_NULL?=%d", jointype == JOIN_UNIQUE_OUTER ? "outer" : "inner", pps == NULL);
+		// JW: pps NULL fix
+		//if (pps != NULL && pps->generate_advice_string)
+		// JW: pps NULL fix with generating SJ also for normal EXPLAIN
+		if (pps != NULL)
 		{
 			bool		found = false;
 
@@ -471,19 +475,27 @@ pgpa_join_path_setup(PlannerInfo *root, RelOptInfo *joinrel,
 				if (root->plan_name == ur->plan_name &&
 					bms_equal(uniquerel->relids, ur->relids))
 				{
+					elog(NOTICE, "found=true! (ur->plan_name=%s bms_ur_relids=%s)", 
+						ur->plan_name, pgpa_bms_to_cstring(ur->relids));
 					found = true;
 					break;
 				}
 			}
+			elog(NOTICE, "found=%d", found);
 
 			/* If not a duplicate, append to the list. */
 			if (!found)
 			{
+				StringInfoData buf;
+
 				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);
+				initStringInfo(&buf);
+				outBitmapset(&buf, uniquerel->relids);
+				elog(NOTICE, "not a dupllicate, appending \"%s\" to pps->sj_unique_rels", buf.data);
 			}
 		}
 	}
@@ -982,7 +994,8 @@ pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
 				jo_deny_indexes = bms_add_member(jo_deny_indexes, i);
 			else if (restrict_method)
 			{
-				jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
+				//JW
+				//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;
@@ -1038,8 +1051,9 @@ pgpa_planner_apply_join_path_advice(JoinType jointype, uint64 *pgs_mask_p,
 				}
 				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);
+				//JW
+				//else
+				//	jo_permit_indexes = bms_add_member(jo_permit_indexes, i);
 			}
 			continue;
 		}
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
index 29973c93b0b..07db28630a8 100644
--- a/contrib/pg_plan_advice/pgpa_walker.c
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -59,6 +59,31 @@ static Index pgpa_walker_get_rti(Index rtable_length,
 								 pgpa_identifier *rt_identifiers,
 								 pgpa_identifier *rid);
 
+
+/*
+ * 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;
+}
+
 /*
  * Top-level entrypoint for the plan tree walk.
  *
@@ -135,6 +160,9 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
 	foreach_ptr(pgpa_query_feature, qf,
 				walker->query_features[PGPAQF_SEMIJOIN_NON_UNIQUE])
 	{
+		elog(NOTICE, "pgpa_plan_walker: walking over SEMIJOIN_NON_UNIQUE features: %s",
+			pgpa_bms_to_cstring(qf->relids));
+
 		if (list_member(sj_unique_rtis, qf->relids))
 			sj_nonunique_qfs = lappend(sj_nonunique_qfs, qf);
 	}
@@ -148,6 +176,8 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
 	foreach_ptr(pgpa_query_feature, qf,
 				walker->query_features[PGPAQF_SEMIJOIN_UNIQUE])
 	{
+		elog(NOTICE, "pgpa_plan_walker: walking over SEMIJOIN_UNIQUE features: %s, sj_unique_rtis=%s sj_unique_rels=%s",
+			pgpa_bms_to_cstring(qf->relids), nodeToString(sj_unique_rtis), nodeToString(sj_unique_rels));
 		if (!list_member(sj_unique_rtis, qf->relids))
 		{
 			StringInfoData buf;
@@ -479,6 +509,11 @@ pgpa_add_future_feature(pgpa_plan_walker_context *walker,
 
 	walker->future_query_features =
 		lappend(walker->future_query_features, qf);
+
+	if(type == PGPAQF_SEMIJOIN_NON_UNIQUE)
+		elog(NOTICE, "added SEMIJOIN_NON_UNIQUE");
+	else if(type == PGPAQF_SEMIJOIN_UNIQUE)
+		elog(NOTICE, "added SEMIJOIN_UNIQUE");
 }
 
 /*

---

From: Robert Haas <[email-masked]>
Date: Thu, 15 Jan 2026 09:40:49 -0500

> from yesterday), but I'm absolutely not sure if that's the proper way to

Thanks. I don't think that's the right fix but the analysis was very
helpful to me in understanding the problem. I think the issue is that
the previous code was confusing "need to generate an advice string"
with "need to walk the plan tree". The latter is a superset of the
former, because we also need to walk the plan tree to generate advice
feedback.

So here's v10. 0001-0004 are unchanged, with the exception that in
0003, I have adjusted accumulate_append_subpath() to incorporate the
absorbed AppendPath's child_append_relid_sets into the surviving
AppendPath's child_append_relid_sets, instead of only the absorbed
AppendPath's relids proper. I don't think this makes any practical
difference to pg_plan_advice, but I might be wrong, and the old way
seems like an obvious oversight. 0005 has a bunch of small fixes,
thanks to all the review comments:

- Fixed mis-spelled Reviewed-by header for Ajay Pal.
- Added Reviewed-by header for John Naylor.
- Added modified version of the test case proposed by Jacob.
- Changed a "break" to "continue" in pgpa_occurrence_number. I don't
think this had any practical impact but it was inconsistent and
contradicted the comment.
- Added a new walk_plan_tree flag to pgpa_planner_state to avoid
getting confused about whether to build pgpa_sj_unique_rel objects.
- Don't let implicit join order constraints arising from join methord
or semijoin uniqueness advice to add to jo_permit_indexes.
- Don't consider join order advice fully matched if we only fully
matched a sublist.
- Remove pgpa_walker_get_rti and properly handle 0 return values from
pgpa_compute_rti_from_identifier instead.

I'm very appreciative to everyone for all the testing and reports
about 0005; I still do need some substantive code review particularly
of 0001.

Thanks,

---

From: Jakub Wartak <[email-masked]>
Date: Mon, 19 Jan 2026 11:53:38 +0100

On Thu, Jan 15, 2026 at 3:41 PM Robert Haas <[email-masked]> wrote:
[..]
> So here's v10.
[..]
> about 0005; I still do need some substantive code review particularly

Hi,

1. With v10 all my minimal TPC-H checks are OK (both with stats/without stats,
   parallel and non-parallel).

2. I couldn't find any glaring issue during code review of v10-000[124]. But I
   have some questions:
   a) v10-0001 - any example producing such a dummy subplan? (whatever
I've tried I
      cannot come up with one)
   b) v10-0001 - maybe we could add a comment nearby "dummy" struct
member to look
      on pgpa_plan_walker() on example how to use it, but that's part of v5 and
      contrib...
   c) In v10-0004, maybe in pathnodes.h we could use typedef enum rather than
      list of #defines? (see attached)

3. Yes, I could too also repro Jacob's and get the same failure, so it's real:
   TRAP: failed Assert("child_target->ttype == PGPA_TARGET_IDENTIFIER"),
   File: "../contrib/pg_plan_advice/pgpa_walker.c", Line: 679, PID: 32344

4. Some raw perf numbers on non-assert builds (please ignore +/- 3%
jumps), it just hurts
   in one scenario where oq2 drops like 9% of juice (quite expected, it's not
   an issue to be, just posting full results)

tps                           oq1  oq2    oq3  oq4
master                        41   14745  439  435
master+v10-000[1-4]           42   15055  439  432
master+v10full                41   14734  429  437
master+v10full+loaded         42   15014  442  438
master+v10full+loaded+advice  41   13481  424  439

(same but in percentages)
%tps_to_master                oq1  oq2    oq3  oq4
master                        100  100    100  100
master+v10-000[1-4]           102  102    100  99
master+v10full                100  100    98   100
master+v10full+loaded         102  102    101  101
master+v10full+loaded+advice  100  91     97   101

Some explanation:
* oq => my shortcut for Optimizer stress Query (to disambiguate from
TPC-H Queries)
* master+v10full+loaded - shared_preloaded_libraries was set to
  have pg_plan_advice
* master+v10full+loaded+advice - as above, but with system-wide GUC set
  to lengthy and irrelevant (as none of the queries used such aliases)
     JOIN_ORDER(x1 x2 x3 x4 x5 x6 x7 x8 x9 x10 x11)
     NESTED_LOOP_PLAIN(x2 x3 x4 x5 x6 x7 x8 x9 x10 x11)
     SEQ_SCAN(x1 x2 x3 x4 x5 x6 x7 x8 x9 x10 x11)
     NO_GATHER(x1 x2 x3 x4 x5 x6 x7 x8 x9 x10 x11)
  The idea was to see how that impacts oq1..4 while not using those.

So out of curiosity the oq2 on 1 CPU core behavior looks like below:
- no advices --> ~1000 TPS
- enabled pg_plan_advice.advice to lengthy, but unrelated thing and it
gets ~890TPS
- in both cases (empty and set) the bottleneck seems to in palloc0, but
    empty plan_advice: it's more like palloc0() <- newNode() <-
create_index_path()
    <- build_index_paths()
    with plan_advice set: palloc0() <- newNode() <- create_nestloop_path() ..
- so if anything people should not put something there blindly, but just SET
  and RESET afterwards (unless we get pinning of SQL plan id to advices) as
  this might have cost in high-TPS scenarios.

-- details about suite for benchmarking:
SELECT 'CREATE TABLE t' || g || ' (id int primary key, val int)'
FROM generate_series(1, 11) g;
\gexec
-- 1k parts
CREATE TABLE tstresspart (id int, val text) PARTITION BY RANGE (id);
SELECT 'CREATE TABLE tpart' || g || ' PARTITION OF tstresspart FOR
VALUES FROM ('
|| g*10 || ') TO (' || (g+1)*10 || ')' FROM generate_series(1, 1000) g;
\gexec

-- oq1, obtakes ~500ms, below GEQO threshold
EXPLAIN SELECT * FROM t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11
WHERE t1.id = t2.id AND t2.id = t3.id AND t3.id = t4.id
  AND t4.id = t5.id AND t5.id = t6.id AND t6.id = t7.id
  AND t7.id = t8.id AND t8.id = t9.id AND t9.id = t10.id
  AND t10.id = t11.id;

-- oq2, hit nested subqueries hard
EXPLAIN SELECT * FROM t1
    WHERE id IN (SELECT id FROM t2
    WHERE id IN (SELECT id FROM t3
    WHERE id IN (SELECT id FROM t4
    WHERE id IN (SELECT id FROM t5
    WHERE id IN (SELECT id FROM t6
    WHERE id IN (SELECT id FROM t7
    WHERE id IN (SELECT id FROM t8
    WHERE id IN (SELECT id FROM t9
    WHERE id IN (SELECT id FROM t10
    WHERE id IN (SELECT id FROM t11))))))))))
OR id IN (SELECT val FROM t1);

-- oq3, part stress test, no part pruning
EXPLAIN SELECT * FROM tstresspart WHERE id = (SELECT (random()*1000));

-- oq4, stress test IN/VALUES
perl -e 'print "SELECT * FROM t1 WHERE id IN ("; for(1..40000)
{ print "$_"; print "," if $_ != 40000 }; print ");"' > oq4.sql

-J.
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index c175ee95b68..772306d8262 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -63,25 +63,27 @@
  * 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
+typedef enum {
+	PGS_SEQSCAN					= 1 << 0,
+	PGS_INDEXSCAN				= 1 << 1,
+	PGS_INDEXONLYSCAN			= 1 << 2,
+	PGS_BITMAPSCAN				= 1 << 3,
+	PGS_TIDSCAN					= 1 << 4,
+	PGS_FOREIGNJOIN				= 1 << 5,
+	PGS_MERGEJOIN_PLAIN			= 1 << 6,
+	PGS_MERGEJOIN_MATERIALIZE	= 1 << 7,
+	PGS_NESTLOOP_PLAIN			= 1 << 8,
+	PGS_NESTLOOP_MATERIALIZE	= 1 << 9,
+	PGS_NESTLOOP_MEMOIZE		= 1 << 10,
+	PGS_HASHJOIN				= 1 << 11,
+	PGS_APPEND					= 1 << 12,
+	PGS_MERGE_APPEND			= 1 << 13,
+	PGS_GATHER					= 1 << 14,
+	PGS_GATHER_MERGE			= 1 << 15,
+	PGS_CONSIDER_INDEXONLY		= 1 << 16,
+	PGS_CONSIDER_PARTITIONWISE	= 1 << 17,
+	PGS_CONSIDER_NONPARTIAL		= 1 << 18
+} PgsScanType;
 
 /*
  * Convenience macros for useful combination of the bits defined above.

---

From: Robert Haas <[email-masked]>
Date: Mon, 19 Jan 2026 14:59:57 -0500

>    a) v10-0001 - any example producing such a dummy subplan? (whatever

If you just add something like this, you can see lots of examples from
the regression tests:

         /* When recursing = true, it's an unplanned or dummy subquery. */
         rtinfo->dummy = recursing;
+        if (rtinfo->dummy)
+            elog(WARNING, "hey look i'm a dummy");

Here's a stripped down example that doesn't require the regression database:

EXPLAIN SELECT * FROM random() UNION SELECT * FROM random() WHERE false;

>       on pgpa_plan_walker() on example how to use it, but that's part of v5 and

Maybe. I think people should know how to grep for stuff like that, and
I don't want to introduce forward dependencies.

>       list of #defines? (see attached)

I personally hate that style and I think Andres loves it. Whee!

>    File: "../contrib/pg_plan_advice/pgpa_walker.c", Line: 679, PID: 32344

I have responded to that separately.

> master+v10full+loaded+advice  100  91     97   101

I think these numbers look pretty good. I mean, there is obviously
room for improvement. We should look at where the CPU cycles are going
in the oq2 case and try to optimize. But even without that, it's not
terrible, IMHO.

> - enabled pg_plan_advice.advice to lengthy, but unrelated thing and it

I'm not sure exactly where the CPU cycles are going here, but one
known problem is that we have to re-parse the advice string for every
query. This thread discusses the challenges of creating some
infrastructure that would allow us to avoid that:

http://postgr.es/m/[email-masked]

Maybe I should start thinking about other ways to avoid that overhead,
because that thread doesn't seem to be progressing much, but maybe the
reparsing isn't even the main problem.

>     with plan_advice set: palloc0() <- newNode() <- create_nestloop_path() ..

I've also seen some palloc-related issues with the patch -- it has to
build some data structures and that does palloc stuff -- but there
shouldn't really be any difference in the code paths you show here.
That's just core code, which should be doing the same thing either way
if the advice is not relevant.

>   this might have cost in high-TPS scenarios.

Yes, I think that's definitely a potential issue. I'd like the
overhead of this module to be as low as possible, but it's bound to
have at least some overhead, and people will have to decide whether
it's worth it.

---

From: Jakub Wartak <[email-masked]>
Date: Tue, 20 Jan 2026 10:19:48 +0100

On Mon, Jan 19, 2026 at 9:00 PM Robert Haas <[email-masked]> wrote:
> > I've tried I cannot come up with one)
[..]
> EXPLAIN SELECT * FROM random() UNION SELECT * FROM random() WHERE false;

Oh well, that was easy, thanks! Now I see `RTI 3 (function,
in-from-clause): /  Subplan: setop_2 (dummy)`

I don't have any further insights on v10-[124] other than mentioned earlier.

> I personally hate that style and I think Andres loves it. Whee!

Oh, ok, nvm, but while two of You we are at this, vim or emacs ? ;)
/me ducks & covers

> Maybe I should start thinking about other ways to avoid that overhead,

Meh...

> if the advice is not relevant.

Yeah, in both it looks like memory allocation and lots of newNode()
called , quite expected.

> have at least some overhead, and people will have to decide whether

I think we should simply ignore, and maybe later just note the fact this is
not free with a single sentence in some docs for 0005. I was just curious of the
impact and this was measured using pure EXPLAIN (so without query execution to
measure impact of non-empty pg_plan_advice), I'm assuming that in
properly managed
systems execution part will always dominate the workload anyway and
one should be
using prepared statements anyway.

-J.

---

From: Ajay Pal <[email-masked]>
Date: Wed, 21 Jan 2026 17:21:57 +0530

Subsequent testing revealed that UNION operations involving constants
which enforce empty subplans result in the generated partition-wise
plan not being recognized by the planner.
Below is the query and output for more details.

CREATE TABLE a_test (id int, category text) PARTITION BY LIST (category);
CREATE TABLE a_active PARTITION OF a_test FOR VALUES IN ('active');
CREATE TABLE a_retired PARTITION OF a_test FOR VALUES IN ('retired');

postgres=# set pg_plan_advice.advice='PARTITIONWISE(unnamed_subquery)';
SET
postgres=# EXPLAIN (PLAN_ADVICE)
SELECT * FROM a_active WHERE id = 1
UNION ALL
SELECT * FROM a_active WHERE id = 2 AND 1=0; -- Constant false forces
                        QUERY PLAN
----------------------------------------------------------
 Seq Scan on a_active  (cost=0.00..25.88 rows=6 width=36)
   Filter: (id = 1)
 Supplied Plan Advice:
   PARTITIONWISE(unnamed_subquery) /* not matched */
 Generated Plan Advice:
   SEQ_SCAN(a_active@unnamed_subquery)
   PARTITIONWISE(unnamed_subquery)
   NO_GATHER(a_active@unnamed_subquery)
(8 rows)

Thanks
Ajay

---

From: Robert Haas <[email-masked]>
Date: Wed, 21 Jan 2026 14:26:02 -0500

> plan not being recognized by the planner.

Good catch, thank you. Should be fixed in this version.

Changes in v11:

- What was previously 0004 has been promoted to 0001. This is the
pgs_mask stuff to allow extensions to control the planner more easily,
which seems separately committable based on reviews to this point.
- Fixed a bug found by Jacob wherein GATHER() and some other tags
permitted multiple levels of nested sublists instead of, as intended,
only a single one.
- Added a test for that case, too.
- Fixed JOIN_ORDER(justonerel) so that it correctly reports "matched"
instead of incorrectly reporting "partially matched" even when
justonerel was part of a join problem.
- Add tests for specifying a prefix of the join order list.
- Removed an XXX comment from the join order tests that now seems bogus to me.
- Fixed a bug in pg_plan_advice_explain_text_multiline that caused it
to display nothing in text format when the label was empty, instead of
when the value was empty.
- Add COSTS OFF to various tests in syntax.sql. I initially omitted
this because I thought it wouldn't be necessary, but I should know
better.
- Add a test for EXPLAIN (PLAN_ADVICE) in non-text format.
- Add tests for SEMIJOIN_UNIQUE() and SEMIJOIN_NON_UNIQUE().
- Rewrite a bunch of buggy logic related to SEMIJOIN_UNIQUE() and
SEMIJOIN_NON_UNIQUE().
- Show the join type in pg_plan_advice.trace_mask output.
- Per this email from Ajay, apply the unique_nonjoin_rtekind() test to
elided (Merge)Append nodes as we do to non-elided ones.

---

From: Robert Haas <[email-masked]>
Date: Mon, 26 Jan 2026 11:07:55 -0500

Here is v12.

The big change in this version is that I've added extensive SGML
documentation for v0005. If the README was a little too low-level for
you, this might work better. If you'd like to view it without
downloading the patch set, I've put it up here:

https://robertmhaas.github.io/postgresql-static/html-pgpa-v12/pgplanadvice.html

Aside from that:

* Added a new GUC pg_plan_advice.always_store_advice_details. Without
that, you can't generate advice or see feedback on supplied advice
when using prepared queries, because we don't know at plan time that
it's right to incur the overhead of generating that stuff, and most of
the time it won't be.
* Revoked privileges on pg_clear_collected_shared_advice() as I had
already done on pg_get_collected_shared_advice().
* Removed a bogus elog(ERROR) in pgpa_walker_would_advise() in favor
of returning 0. I think somebody, likely Jakub, pointed this out
earlier, but I didn't quite absorb what I was being told until I
rediscovered the problem.
* Added a bunch more tests. I think the test coverage is getting
pretty decent now, but it could still use some tests targeting more
complex scenarios and corner cases. If you are curious about the
coverage report, see here:

https://robertmhaas.github.io/postgresql-static/coveragereport-pgpa-v12/contrib/pg_plan_advice/index.html

The low number for pgpa_scanner.l is basically bogus, but I don't know
of a way to make it not bogus. The low number for pgpa_ast.c is due to
a bunch of things related to bitmap scans not being right, which at
this point is, I think, the largest outstanding issue with the patch.
It's probably more interesting to look into ways of covering a few
more lines from pgpa_planner.c and pgpa_walker.c, which is where a lot
of the complexity in this code lives. Also, it would be nice to have
coverage of foreign scan cases, but I'm not quite sure what I need to
do to create tests for this module that also depend on postgres_fdw.
Any tips appreciated.

---

From: Ajay Pal <[email-masked]>
Date: Tue, 27 Jan 2026 13:19:38 +0530

Hi,

with v12 patch, found below observations,

#1 Grouped Hash Join, This forces the join of dim1 and dim2 to happen
first, and then places that resulting set on the inner side of a Hash
Join against fact.
but the planner partially matches the generated advice.

CREATE TABLE fact (f_id int, d1_id int, d2_id int, d3_id int);
CREATE TABLE dim1 (id int PRIMARY KEY, val text);
CREATE TABLE dim2 (id int PRIMARY KEY, val text);
CREATE TABLE dim3 (id int PRIMARY KEY, val text);

INSERT INTO fact SELECT g, g%10, g%10, g%10 FROM generate_series(1, 10000) g;
INSERT INTO dim1 SELECT g, 'd1-'||g FROM generate_series(0, 9) g;
INSERT INTO dim2 SELECT g, 'd2-'||g FROM generate_series(0, 9) g;
INSERT INTO dim3 SELECT g, 'd3-'||g FROM generate_series(0, 9) g;
ANALYZE fact, dim1, dim2, dim3;

-- We want (dim1 JOIN dim2) to be the inner side of a Hash Join
SET LOCAL pg_plan_advice.advice = 'HASH_JOIN((dim1 dim2))';

postgres=*# EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM fact
                  JOIN dim1 ON fact.d1_id = dim1.id
                  JOIN dim2 ON fact.d2_id = dim2.id;
                        QUERY PLAN
-----------------------------------------------------------
 Nested Loop
   Disabled: true
   ->  Nested Loop
         Disabled: true
         ->  Seq Scan on fact
         ->  Index Scan using dim1_pkey on dim1
               Index Cond: (id = fact.d1_id)
   ->  Index Scan using dim2_pkey on dim2
         Index Cond: (id = fact.d2_id)
 Supplied Plan Advice:
   HASH_JOIN((dim1 dim2)) /* partially matched */
 Generated Plan Advice:
   JOIN_ORDER(fact dim1 dim2)
   NESTED_LOOP_PLAIN(dim1 dim2)
   SEQ_SCAN(fact)
   INDEX_SCAN(dim1 public.dim1_pkey dim2 public.dim2_pkey)
   NO_GATHER(fact dim1 dim2)
(17 rows)

#2 Multiple Instances of Same Table in Subqueries, here target the
second instance of dim1 inside the subquery 'sq'. both seq_scan and
index_scan advices are not matching.

SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(dim1#2@sq)
INDEX_SCAN(dim1@sq dim1_pkey)';

postgres=*# EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM fact
JOIN (
    SELECT a.id FROM dim1 a
    JOIN dim1 b ON a.id = b.id
    OFFSET 0
) sq ON fact.d1_id = sq.id;
                    QUERY PLAN
---------------------------------------------------
 Hash Join
   Hash Cond: (fact.d1_id = b.id)
   ->  Seq Scan on fact
   ->  Hash
         ->  Seq Scan on dim1 b
 Supplied Plan Advice:
   SEQ_SCAN(dim1#2@sq) /* not matched */
   INDEX_SCAN(dim1@sq dim1_pkey) /* not matched */
 Generated Plan Advice:
   JOIN_ORDER(fact sq)
   HASH_JOIN(sq)
   SEQ_SCAN(b@sq fact)
   NO_GATHER(fact b@sq)
(13 rows)

Thanks
Ajay

---

From: Robert Haas <[email-masked]>
Date: Tue, 27 Jan 2026 07:32:11 -0500

>                   JOIN dim2 ON fact.d2_id = dim2.id;

Thanks for the report, but this is actually correct behavior. There's
no join clause between dim1 and dim2, so the planner doesn't consider
a dim1-dim2 join. This is a good example of the phenomenon described
in the documentation: you can't force the planner to create an
arbitrary plan that it wouldn't otherwise have considered. I might
tweak the documentation wording a little to try  to mention that this
is another way "partially matched" can happen, but there's no bug
here.

> ) sq ON fact.d1_id = sq.id;

I'm not sure what why you expected this to work. You can see what the
correct relation identifiers are from the generated plan advice, and
you've used something else, so it doesn't match. It's documented in
both the SGML documentation and the README that relation identifiers
are based on the relation alias, not the relation name.

In general, this seems like a good to reiterate that this is first and
foremost a plan stability feature. More than anything, these examples
show that if you try to write your own plan advice from scratch to
force a novel plan that the planner has never produced itself, you may
not have much luck. If you do want to try to produce a novel plan, you
should at least look at the generated plan advice and adapt it instead
of starting from scratch. And if you find, when trying to produce a
novel plan, that it doesn't work, you need to consider the possibility
that this is because the optimizer did not ever consider that plan,
and that is why pg_plan_advice is unable to induce the planner to
prefer it. That's not to say there can't be any remaining bugs in
pg_plan_advice; there probably are. But it also is absolutely not a
"write your own plan and do anything you like" feature.

---

From: Ajay Pal <[email-masked]>
Date: Tue, 27 Jan 2026 18:18:46 +0530

Thank you Robert for clarification.

---

From: Robert Haas <[email-masked]>
Date: Wed, 28 Jan 2026 12:13:39 -0500

On Mon, Jan 12, 2026 at 12:13 PM Robert Haas <[email-masked]> wrote:
> If not, I'll rearrange

That rearrangement got done in v11, and I've now committed that patch
after fixing a few typos that I found. Cautiously, woohoo, but let's
see if anything breaks.

Here's v13. Aside from dropping the now-committed patch, the other big
change here is that I've fixed the way bitmap heap scans work by
reducing scope. Previously, you could write things like
BITMAP_HEAP_SCAN(foo some_foo_idx) or BITMAP_HEAP_SCAN(foo
&&(foo_a_idx foo_b_idx)) to try to compel a particular choice of index
with a bitmap heap scan; however, it didn't actually work. I've now
removed that, and now all of the arguments to BITMAP_HEAP_SCAN() are
relation identifiers, and it specifies only that they should use some
kind of bitmap heap scan. I'm not sure that this is the right thing to
do, but it might be, for a couple of reasons:

1. The decision as to what should be included in a bitmap path is not
made by the general costing machinery, but by choose_bitmap_and(),
which uses its own special algorithm. A general principle of
pg_plan_advice is that it doesn't let you force the optimizer to
consider options that are rejected for reasons other than cost. This
is a grey area. choose_bitmap_and() considers cost, but it also has
other heuristics to limit the search space. We could allow overriding
the former part of the heuristic but not the latter, but that seems
complicated, and might also make this whole thing even more confusing
to use, since it would be really unclear why you could force some
index combinations and not others. We could also allow forcing any
combination, but that needs a lot of thought, since it deviates from
the general design principle and might open a king-sized can of worms.
Point being, the idea that BITMAP_HEAP_SCAN() has no business allowing
an index specification in the first place is not completely without
merit.

2. The only situations in which we consider multiple actual bitmap
paths (vs. potential bitmap paths, as discussed in the previous point)
is when there are possibly-useful parameterizations that we don't want
to ignore. However, I studied what happens in the core regression
tests, and it seems like we don't really have that many test cases
where just forcing some kind of bitmap heap scan wouldn't be good
enough. To use a parameterized bitmap heap scan path, we have to be
under the inner side of a nested loop with the parameterized rel on
the other side, so if the advice produces any other join order or join
method, then the parameterized paths won't even be considered and
there's only one path to choose from. However, it is true that we
might make the wrong decision about which bitmap path to use on the
inner side of a parameterized nested loop. In the future, that could
be addressed either by adding control over choice of parameterization
or by re-adding something like what I had in earlier patch versions
where you can specify particular indexes. But. I don't think we need
to decide right now which of those things we might ultimately want to
do.

(Another interesting point is that in a healthy number of cases where
we consider both parameterized and unparameterized bitmap paths, we
consider the same indexes or sets of indexes in both cases.)

I've gone ahead and removed the "WIP" designation from the
pg_plan_advice patch in this version. There are still a few areas that
need some more investigation, and I'm sure there are still bugs, but I
feel like the bitmap scan thing was the last really big area where
there was a huge problem staring any potential reviewer right in the
face. The remaining XXX comments are things where review comments
would actually be pretty helpful -- not to tell me that I have an XXX
comment, but to suggest what the resolution might be. Of course, I'll
continue looking into that on my own, as well.

Thanks to all who have reviewed so far, and please keep it coming. I
am especially in need of more code review at this point.

---

From: Zsolt Parragi <[email-masked]>
Date: Wed, 28 Jan 2026 23:40:49 +0000

Hello

Just noticed this in the committed patch, it doesn't seem intentional:
(the last line wasn't part of the patch, probably an accidental leftover)

src/backend/optimizer/ptah/costsize.c:1462

        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;

---

From: Tom Lane <[email-masked]>
Date: Wed, 28 Jan 2026 21:41:18 -0500

> after fixing a few typos that I found. Cautiously, woohoo, but let's

skink's not happy:

=2162940== VALGRINDERROR-BEGIN
==2162940== Conditional jump or move depends on uninitialised value(s)
==2162940==    at 0x434D366: cost_material (costsize.c:2593)
==2162940==    by 0x436E7E9: materialize_finished_plan (createplan.c:6531)
==2162940==    by 0x4383054: standard_planner (planner.c:533)
==2162940==    by 0x4383541: planner (planner.c:324)
==2162940==    by 0x44C50B4: pg_plan_query (postgres.c:905)
==2162940==    by 0x4237444: standard_ExplainOneQuery (explain.c:354)
==2162940==    by 0x42375DF: ExplainOneQuery (explain.c:310)
==2162940==    by 0x4237A7A: ExplainOneUtility (explain.c:458)
==2162940==    by 0x42375C3: ExplainOneQuery (explain.c:301)
==2162940==    by 0x4237745: ExplainQuery (explain.c:224)
==2162940==    by 0x44CC834: standard_ProcessUtility (utility.c:868)
==2162940==    by 0x44CD09D: ProcessUtility (utility.c:525)
==2162940==  Uninitialised value was created by a stack allocation
==2162940==    at 0x436E76A: materialize_finished_plan (createplan.c:6506)
==2162940== 
==2162940== VALGRINDERROR-END

			regards, tom lane

---

From: "David G. Johnston" <[email-masked]>
Date: Wed, 28 Jan 2026 21:58:26 -0700

On Wed, Jan 28, 2026 at 10:14 AM Robert Haas <[email-masked]> wrote:

> Here's v13.

Documentation review patch attached.

Overall it flowed well.  Most of the changes are just typos or similar.

I found the idea of seeing "Disabled: true" to not make immediate sense.
I've left some comments near that section and plan to come back to it.

The comment about collector memory usage was duplicated more-or-less.  I
kept the first, less loud, instance.

Removed the few uses of "we" in favor of pg_plan_advice being the actor.

David J.
From bc40cb9dd2c1c3ac27c44ecbbae6c450745879d6 Mon Sep 17 00:00:00 2001
From: "David G. Johnston" <[email-masked]>
Date: Wed, 28 Jan 2026 21:47:45 -0700
Subject: [PATCH] Docs review

---
 doc/src/sgml/pgplanadvice.sgml | 115 +++++++++++++++++----------------
 1 file changed, 58 insertions(+), 57 deletions(-)

diff --git a/doc/src/sgml/pgplanadvice.sgml b/doc/src/sgml/pgplanadvice.sgml
index b319f90febe..07c3d2a0121 100644
--- a/doc/src/sgml/pgplanadvice.sgml
+++ b/doc/src/sgml/pgplanadvice.sgml
@@ -21,7 +21,7 @@
   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 risks of constraining the planner's choices are outweighed by
+  only when the risks of constraining the planner's choices are outweighed by
   the benefits.
  </para>
 
@@ -39,7 +39,7 @@
    <link linkend="sql-load"><literal>LOAD</literal></link> command. If you
    wish to use the
    <link linkend="pgplanadvice-collector">collector interface</link>,
-   you must also the <literal>pg_plan_advice</literal> extension
+   you must also install the <literal>pg_plan_advice</literal> extension
    in the database where you wish to use the collector. Use the command
    <literal>CREATE EXTENSION pg_plan_advice</literal> to do this. If you do
    not wish to use the collector interface, this step is not required.
@@ -48,8 +48,8 @@
   <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:
+   a <literal>PLAN_ADVICE</literal> option. You can use this option to generate
+   the plan advice pertaining to the chosen plan. For example:
   </para>
 
 <programlisting>
@@ -99,9 +99,9 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE)
    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,
+   chosen. This can be an advice string that was generated by <literal>pg_plan_advice</literal>,
    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
+   string is to take the string generated by <literal>pg_plan_advice</literal> 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
@@ -146,19 +146,19 @@ EXPLAIN (COSTS OFF)
 
   <para>
    Plan advice is written imperatively; that is, it specifies what should be
-   done. However, at an implementation level,
+   done. However, at the 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
+   planner's choices, not by dictating them. 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
+   planner fails to produce the desired plan, there is either
+   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 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
+   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
@@ -176,6 +176,12 @@ EXPLAIN (COSTS OFF)
    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>
+  <!-- this whole disabled thing needs work for me to understand
+       the output "Advice Ignored: JOIN_ORDER(x f d)" would be nicer.
+       but maybe it cannot be that specific...noting that when Advice
+       is ultimately ignored the chosen option may not match the choice
+       that would have happened had no advice been given.
+  -->
 
 <programlisting>
 SET pg_plan_advice.advice = 'JOIN_ORDER(x f d)';
@@ -218,8 +224,8 @@ EXPLAIN (COSTS OFF)
    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
+   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>
@@ -234,22 +240,22 @@ alias_name#occurrence_number/partition_schema.partition_name@plan_name
 
   <para>
    All components except for the <literal>alias_name</literal> are optional
-   and included only when required. When a component is omitted, the associated
+   and are included only when required. When a component is omitted, the preceding
    punctuation must also be omitted. For the first occurrence of a table
    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, we always incude both, but it is legal to
-   omit the schema. The plan name is omitted for the toplevel plan, and must
-   be included for any subplan.
+   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
+   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 toplevel). Furthermore, the correct subquery name is sometimes not
+   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
@@ -301,23 +307,21 @@ BITMAP_HEAP_SCAN(<replaceable>target</replaceable> [ ... ])</synopsis>
    </para>
 
    <para>
-    <literal>FOREIGN_SCAN</literal> specifies that a foreign join between
-    a several foreign tables should be pushed down to a remote server so
+    <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 implenented 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
+    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
+    While the planner support additional scan types most are omitted as
+    there are no meaningful options available to them to be advised to
+    prefer.
+    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.
+    can only ever be scanned using a <literal>Function Scan</literal>.
    </para>
 
   </sect3>
@@ -337,7 +341,7 @@ JOIN_ORDER(<replaceable>join_order_item</replaceable> [ ... ])
     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
+    specified. For example, <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 tables in the query than <literal>a</literal>,
@@ -348,7 +352,7 @@ JOIN_ORDER(<replaceable>join_order_item</replaceable> [ ... ])
    <para>
     If a <literal>JOIN_ORDER</literal> list contains a parenthesized sublist,
     it specifies a non-outer-deep join. The tables in the sublist must first
-    be joined to each other much as if the sublist were a toplevel
+    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
@@ -366,12 +370,12 @@ Join
 </programlisting>
 
    <para>
-    If a <literal>JOIN_ORDER</literal> list contains a sublist surronded by
+    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, specifiying
-    <literal>JOIN_ORDER(a {b c} d</literal> would allow the scans of
+    <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 parenthese are used.
+    previous example, which is not allowed when parentheses are used.
    </para>
 
    <para>
@@ -401,7 +405,7 @@ join_method_name(<replaceable>join_method_item</replaceable> [ ... ])
 
 <phrase>and <replaceable>join_method_item</replaceable> is:</phrase>
 
-<replaceable>advice_target</replaceable> |
+{ <replaceable>advice_target</replaceable> |
 ( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
 
    <para>
@@ -409,13 +413,13 @@ join_method_name(<replaceable>join_method_item</replaceable> [ ... ])
     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 confirming plan must contain at least two hash
+    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
+    inner side, and the other having <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 a single hash join.
+    on the inner side of a single hash join.
    </para>
 
    <para>
@@ -442,7 +446,7 @@ PARTITIONWISE(<replaceable>partitionwise_item</replaceable> [ ... ])
 
 <phrase>where <replaceable>partitionwise_item</replaceable> is:</phrase>
 
-<replaceable>advice_target</replaceable> |
+{ <replaceable>advice_target</replaceable> |
 ( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
 
    <para>
@@ -465,7 +469,7 @@ 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> |
 ( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
 
    <para>
@@ -488,7 +492,7 @@ NO_GATHER(<replaceable>advice_target</replaceable> [ ... ])
 
 <phrase>where <replaceable>gather_item</replaceable> is:</phrase>
 
-<replaceable>advice_target</replaceable> |
+{ <replaceable>advice_target</replaceable> |
 ( <replaceable>advice_target</replaceable> [ ... ] ) }</synopsis>
 
    <para>
@@ -559,7 +563,7 @@ rhaas=# EXPLAIN (COSTS OFF)
    <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 separtely, together with all the feedback applicable
+   each logical request separately, together with all the feedback applicable
    to that request type.
   </para>
 
@@ -579,7 +583,7 @@ rhaas=# EXPLAIN (COSTS OFF)
    <listitem>
     <para>
      <literal>partially matched</literal> means that some but not all of the
-     specified advice targetes were observed during query planning.
+     specified advice targets were observed during query planning.
     </para>
    </listitem>
 
@@ -587,7 +591,7 @@ rhaas=# EXPLAIN (COSTS OFF)
     <para>
      <literal>not matched</literal> means that none of the
      specified advice targets were observed during query planning. This may
-     be happen if the advice simply doesn't match the query, or it 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>
@@ -626,7 +630,7 @@ rhaas=# EXPLAIN (COSTS OFF)
      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.
+     the advice string; only the planner knows what actually works.
     </para>
    </listitem>
 
@@ -651,18 +655,15 @@ rhaas=# EXPLAIN (COSTS OFF)
   <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
+   limit, the oldest queries and corresponding advice will be discarded.
+   Then, you must separately enable advice
    collection. For the local collector, set the collection limit by configuring
    <literal>pg_plan_advice.local_collection_limit</literal> to a value
    greater than zero, and then enable advice collection by setting
    <literal>pg_plan_advice.local_collector = true</literal>. For the shared
    collector, the procedure is the same, except that the names of the settings
    are <literal>pg_plan_advice.shared_collection_limit</literal> and
-   <literal>pg_plan_advice.shared_collector</literal>. Note that the local
-   collector stores query texts and advice strings in backend-local memory,
-   and the shared collector does the same in dynamic shared memory, so
-   configuring large limits may result in considerable memory consumption.
+   <literal>pg_plan_advice.shared_collector</literal>.
   </para>
 
   <para>
@@ -672,7 +673,7 @@ rhaas=# EXPLAIN (COSTS OFF)
    <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
+   and release memory, you call
    <literal>pg_clear_collected_local_advice()</literal>
    or <literal>pg_clear_collected_shared_advice()</literal>.
   </para>
@@ -684,8 +685,8 @@ rhaas=# EXPLAIN (COSTS OFF)
    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>enable_query_id = on</literal>. Otherwise, the query ID may
-   always show as <literal>0</literal>.
+   <literal>enable_query_id = on</literal>. Otherwise, the query ID will
+   show as <literal>0</literal>.
   </para>
 
  </sect2>
@@ -829,9 +830,9 @@ rhaas=# EXPLAIN (COSTS OFF)
       <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, we do not
+      <literal>false</literal>.  When planning a prepared query, <literal>pg_plan_advice</literal> does not
       know whether <literal>EXPLAIN</literal> will be used, so by default, to
-      reduce overhead, we do not generate plan advice, and we do not generate
+      reduce overhead, it does not generate plan advice, and does not generate
       feedback on supplied advice. This means that if
       <literal>EXPLAIN EXECUTE</literal> is later used on the prepared query,
       it will not be able to show this information. Changing this setting to
-- 
2.43.0

---

From: Lukas Fittl <[email-masked]>
Date: Wed, 28 Jan 2026 21:44:26 -0800

> > after fixing a few typos that I found. Cautiously, woohoo, but let's

Yay!

>From a quick look, I think that's a missing initialization of
path->parallel_workers when cost_material gets called with the dummy
path created in materialize_finished_plan.

The indirect relationship between those functions doesn't seem great
(i.e. anything that gets read in cost_material has to be initialized
in materialize_finished_plan). What if we just zero initialize the
dummy Path structure instead, like in the attached?

Thanks,
Lukas

--
Lukas Fittl

---

From: Richard Guo <[email-masked]>
Date: Thu, 29 Jan 2026 15:44:24 +0900

> > ==2162940==    at 0x436E76A: materialize_finished_plan (createplan.c:6506)

FWIW, the following related code in reparameterize_path() seems unsafe
to me:

    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, enabled);

I think we should access spath->disabled_nodes until after we have
verified that spath is not NULL.

Also, it's not quite clear to me why create_material_path (and
cost_material) requires the "enabled" parameter.  I couldn't find any
comments on this, so it might be worth adding some comments here.

- Richard

---

From: Robert Haas <[email-masked]>
Date: Thu, 29 Jan 2026 08:33:13 -0500

>         path->disabled_nodes = 0;

Good catch. I have included a fix for this in
4020b370f214315b8c10430301898ac21658143f.

---

From: Robert Haas <[email-masked]>
Date: Thu, 29 Jan 2026 08:34:25 -0500

> skink's not happy:

Thanks for the report. 4020b370f214315b8c10430301898ac21658143f
includes an attempt at a fix.

---

From: Robert Haas <[email-masked]>
Date: Thu, 29 Jan 2026 08:35:43 -0500

> dummy Path structure instead, like in the attached?

This might be the right answer, but I didn't want to add more memory
zeroing than needed without discussion, so I have just pushed a
minimal fix for now. Thanks for the analysis.

---

From: Robert Haas <[email-masked]>
Date: Thu, 29 Jan 2026 09:10:11 -0500

> comments on this, so it might be worth adding some comments here.

I think cost_material() got an enabled argument because of
materialize_finished_plan(). Most of the time, plan nodes are created
from paths, and we want to use the parent's pgs_mask to determine
whether the chosen strategy (e.g. materialization) is enabled.
However, materialize_finished_plan() creates a plan directly, so
there's no RelOptInfo or JoinPathExtraData whose pgs_mask we can
consult, and so we have to fall back on consulting enable_material
directly. Once that parameter got added to cost_material(), it made
sense to me to add it to create_material_path() as well.

There are probably other ways I could have done this. For instance, we
could make create_material_path() pass true to cost_material(), and
not have its own "enabled" argument. That would mean that
reparameterize_path() would have to unconditionally pass true. I'm not
sure if that would be a problem. Another idea is to make
cost_material() consult enable_material itself if path->parent is
NULL, so that it wholly encapsulates the calculation locally.

One thing that is a little different about materialization vs. some
other operations is that it's used for multiple purposes. Sometimes we
use it because it's required for correctness, and other times we do it
to reduce cost. And, it can be used to affect cost in different
scenarios. In the Nested Loop case, PGS_NESTLOOP_MATERIALIZE should
control whether we are willing to materialize, but that doesn't apply
to other cases, like build_subplan() or create_mergejoin_plan().
Moreover, these latter cases have not yet been converted to use Paths
-- hopefully they will be converted someday, but maybe not soon. All
of this makes things a little more complicated for materialization vs.
something like a nested loop or a sequential scan, where the logic to
create that thing is all in one place and paths are in use.

But that is not to say that I'm deeply invested in cost_material()
having an "enabled" argument -- that seems like a detail to me that
could go either way. If it seems objectionable, we could change it,
and I think we could probably do so in more than one way and without
really affecting the behavior. Alternatively, we could leave it the
way it is and add a comment clarifying whatever portion of the above
you think is worth documenting in the source. It seemed like a
sufficiently minor detail to me that I did not bother, but that might
have been a mistake.

---

From: Alexandra Wang <[email-masked]>
Date: Mon, 2 Feb 2026 11:36:47 -0800

Hi Robert,

> am especially in need of more code review at this point.

Thanks for the patches! I’ve reviewed 0001 - 0003 so far; here are my
comments.

0001:
The code looks good to me. However, I feel a bit uneasy about not
seeing a test case for the additional subplan origin display added in
pg_overexplain. Maybe we could add the following test cases to
exercise that code:

-- should show "Subplan: sub"
EXPLAIN (RANGE_TABLE, COSTS OFF)
SELECT * FROM vegetables v,
       (SELECT * FROM vegetables WHERE genus = 'daucus' OFFSET 0) sub;

-- should show "Subplan: unnamed_subquery"
EXPLAIN (RANGE_TABLE, COSTS OFF)
SELECT * FROM vegetables v,
       (SELECT * FROM vegetables WHERE genus = 'daucus' OFFSET 0);

0002:
Looks good to me.

0003:
I see code like this:

@@ -2232,6 +2251,11 @@ 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);
+ *child_append_relid_sets =
+ list_concat(*child_append_relid_sets,
+ apath->child_append_relid_sets);

in accumulate_append_subpath(), but in get_singleton_append_subpath()
there are only calls to lappend() and no list_concat(). Is that
intentional? Do we also want to concatenate the newly pulled up
child_append_relid_sets with the existing ones in
get_singleton_append_subpath()?

In add_paths_to_append_rel():

@@ -1785,13 +1790,16 @@ add_paths_to_append_rel(PlannerInfo *root,
RelOptInfo *rel,
        {
            Path       *path = (Path *) lfirst(l);
            AppendPath *appendpath;
+           AppendPathInput append = {0};
+
+           append.partial_subpaths = list_make1(path);
+           append.child_append_relid_sets = list_make1(rel->relids);

Could you help me understand why we need to populate
append.child_append_relid_sets here? I don’t see this child rel being
pulled up at this point.

0004:
I’ve only read through the README and documentation so far; I’ll
continue reviewing the code in 0004.

Best,
Alex

-- 
Alexandra Wang
EDB: https://www.enterprisedb.com

---

From: Robert Haas <[email-masked]>
Date: Sat, 7 Feb 2026 11:44:39 -0500

> sense to me to add it to create_material_path() as well.

If this explanation seems a little weak, it's because it was. Here's a
better explanation: the patch added a per-rel flag
PGS_NESTLOOP_MATERIALIZE that enables the use of a Nested Loop join
with an inner Materialize node. When we construct such a path, the
"parent" point for the materialize path points to the rel that is on
the inner side of the join, not the joinrel itself. But the pgs_mask
flags of the joinrel and its inner side could be different. Therefore,
the Materialize node can't just look at path->parent->pgs_mask to
decide whether to mark the node as disabled. So, when I wrote the
patch originally, I added an enabled flag here to make sure that we
pass down the information about whether the join method was enabled at
the joinrel level, since the joinrel's pgs_mask is not otherwise
available to cost_material(). Later on, I discovered the need for
PGS_CONSIDER_NONPARTIAL, but by then I had forgotten why that
"enabled" argument existed and pushed the logic to handle
PGS_CONSIDER_NONPARTIAL into cost_material(). So the logic as
currently committed is buggy in the case where the joinrel and the
innerrel have differing pgs_mask values. match_unsorted_outer() and
consider_parallel_nestloop() have the intention of generating
Materialize nodes only when materialization is enabled, so that the
Materialize nodes are never marked disabled. But with the patch as
committed, a non-partial Nested Loop with inner Materialize can
disable the Materialize node if the inner rel's pgs_mask has
PGS_CONSIDER_NONPARTIAL unset.

The right fix is to make all the decisions about whether the
Materialize nod should be created, and whether it should be enabled,
in the caller, and have none of that logic in cost_material(). I will
post a new patch set shortly, which will include a patch to rectify
this issue.

---

From: Robert Haas <[email-masked]>
Date: Sat, 7 Feb 2026 11:46:17 -0500

> Documentation review patch attached.

Thanks for the review. I think I agree with some of these changes but
not all of them, but I haven't quite had time to look through them
yet, so the next version of the patch set will not include any of
them. However, I will come back around to this next week and look
through all of your comments.

---

From: Robert Haas <[email-masked]>
Date: Sat, 7 Feb 2026 12:37:56 -0500

Here is a new patch set (v14).

I finally got time to implement an idea I've been thinking about for a
while, which is to run the regression tests planning every query
twice. The first time, we generate plan advice. The second time, we
replan using that advice to guide planning. If everything in the world
is wonderful, this shouldn't cause the regression tests to fail. Of
course, it did, so this patch set includes a bunch of changes to both
make this kind of testing possible and to fix the issues thus
revealed. Here's a rundown of the problems I discovered, some of which
were problems in pg_plan_advice, some of which are problems created by
my recently committed patches, and a few of which are long-standing
planner problems that just haven't been very apparent up until now.

0. I couldn't actually write the test code to do the above because
planner_setup_hook doesn't get cursorOptions as an argument, so having
the planner setup hook call back into planner() wasn't easy to do
correctly. I've added a new preparatory patch (0002) to fix it. I plan
to proceed with this patch quickly unless there are objections, since
planner_setup_hook is brand new and there seems to be no downside to
fixing this quickly.

1. A bunch of tests failed because they use debug_parallel_query=on.
That introduced a single-copy Gather node into the plan, which caused
pg_plan_advice to emit GATHER() advice. When the queries are then
replanned, they use a real Gather node, not a single-copy one. I've
updated pg_plan_advice to disregard single-copy Gather nodes.

2. A bunch of tests failed because pg_plan_advice was assuming, in
some places, that it was the only thing adjusting pgs_mask. But, a lot
of our regression tests run under enable_SOMETHING=false. I adjusted
pg_plan_advice so that it only ever *clears* bits from pgs_mask
instead of sometimes being willing to set them. This means that the
effects of enable_SOMETHING=false and pg_plan_advice are additive,
rather than (as in previous versions) letting pg_plan_advice sometimes
override the GUC value for no particular reason.

3. One test failed because cost_material() tries to check
PGS_CONSIDER_NONPARTIAL instead of relying on the caller to set the
"enabled" flag properly. See
http://postgr.es/m/[email-masked]
or patch 0001 for details and a fix. I plan to commit this one
relatively quickly, too, barring objections.

4. One test failed because setting enable_indexonlyscan=false, or
clearing the equivalent pgs_mask bit, can cause a Bitmap Heap Scan not
to be used. I worked around this in pg_plan_advice, but I think we
should consider doing something about it in the core planner. See
http://postgr.es/m/[email-masked]
for all the details of what's happening here.

5. One test failed because pg_plan_advice was forcing the use of a
particular index by deleting all the others from rel->indexlist in its
get_relation_info_hook. This is currently the only method we have for
a loadable module to force the use of an index, but the problem is
that it makes the index invisible for all purposes, and this
particular test relies on self-join elimination. The query can't try
to force the use of index #1 without hiding the existence of index #2
from the SJE code, and then we're in trouble. I've also verified that
deleting elements from the index list can break left join removal. So
I think we need a way to tell the planner that we don't want a certain
index to be scanned without telling it that the index doesn't exist at
all. Therefore, I've revived my previous patch to add a 'disabled'
flag to IndexOptInfo. At the time I last proposed that, Tom said it
wasn't needed because we could just delete from the index list, and I
didn't have any reason why we couldn't just do that, so I made
pg_plan_advice work that way. But now I do have a reason, so brought
that patch back as 0006 and adjusted pg_plan_advice to use it.

6. One tests failed because add_partial_path() doesn't consider
startup cost as a figure of merit. There's a comment there written by
me at the time I was introducing parallel query, back in 2016, which
claims it isn't necessary, but by 2020, while looking at incremental
sort, James Coleman had figured out that this was incorrect. In brief,
what happens here is that the query plan doesn't use a sequential
scan, but disabling sequential scans changes the final plan, and in
fact the new plan has a lower cost than the old one. For full details
and proposed patches, see
http://postgr.es/m/[email-masked]
-- I've also incorporated those patches into this patch set as 0008
and 0009, so that the new testing framework added in 0010 will
actually pass.

So here's an overview of the new patch set.

0001. New patch: fix PGS_CONSIDER_NONPARTIAL interaction with
Materialize nodes. See point (3) above.
0002. New patch: pass cursorOptions to planner_setup_hook. See point (0) above.
0003-0005. These were 0001-0003 in the previous patch set, and I have
included the changes suggested by Alexandra Wang in her review.
0006. Revived patch from an old thread: Allow extensions to mark an
individual index as disabled. See point (5) above.
0007. pg_plan_advice. This was 0004 in the previous patch set. This
has a few updates:
- It has been adjusted for the new 0001, 0002, and 0006 patches.
- The issues mentioned in points (1), (2), and (4) above have been fixed.
- There's now a system for other plugins to add "advisors", which is a
way for that other module to supply an advice string that can be used
during query planning. Here, I've done that to enable the testing
described above, but it could also be used by a module that wants to
do on-the-fly advice injection.
- Minor cosmetic improvements, mostly reordering some functions in
pgpa_planner.c for (IMHO) better clarity.
0008-0009. My proposed patches to address add_partial_path's failure
to consider startup costs and related bugs I found while
investigating. See point (6) above.
0010. New patch: add src/test/modules/test_plan_advice, implementing
the testing procedure described above.

I think there's quite a bit more that can be done with this new
testing methodology to find additional issues here, and I plan to
pursue that. But I'm fairly happy with the results of this initial
round of testing. On the other hand, it flushed out a bunch of issues
that seem worth fixing. But it also didn't break a hundred things for
a hundred different reasons, which seems good, too.

---

From: Alexandra Wang <[email-masked]>
Date: Mon, 9 Feb 2026 07:55:17 -0800

Hi Robert,

> Here is a new patch set (v14).

Thanks for the patches! 0003 - 0005 look good to me.

Thanks,
Alex

---

From: Robert Haas <[email-masked]>
Date: Tue, 10 Feb 2026 18:03:34 -0500

> Thanks for the patches! 0003 - 0005 look good to me.

I have committed those, as well as 0001 and 0002. Here's v15. The main
patch is now 0002, and has the following changes since the last
version:

- Added a new GUC pg_plan_advice.feedback_warnings, disabled by
default, which can be set to true to produce a warning about plan
advice strings that aren't fully working. (Previously, you had to use
EXPLAIN to get this information.)

- Use get_namespace_name_or_temp, rather than get_name_namespace,
consistently. One use of the latter function crept in, breaking
INDEX_SCAN and INDEX_ONLY_SCAN advice for temporary tables.

- Fix a problem in pgpa_scan.c that could cause spurious NO_GATHER
advice to be generated in certain situations, such as when joins were
proven empty.

- Fix a logic error in the handling of JOIN_ORDER advice that could
cause it to be marked as conflicting with PARTITIONWISE advice when
that was not in reality the case.

- Incorporate documentation corrections from David G. Johnston. I
didn't take all of his suggestions, but I took many of them, sometimes
with some additional wordsmithing on my part.

- Remove a stray comment.

Also a reminder that 0003 and 0004 (previously 0008 and 0009) don't
properly belong to this thread, but I've included them here because
otherwise the tests in the last patch don't pass. See
http://postgr.es/m/[email-masked]
for discussion of those patches.

---

From: Ajay Pal <[email-masked]>
Date: Thu, 12 Feb 2026 17:11:25 +0530

Hi,

pg_plan_advice failed to match JOIN_ORDER advice because the genetic
algorithm never attempts the specific join path requested.

Test SQL:

LOAD 'pg_plan_advice';
SET pg_plan_advice.always_explain_supplied_advice = on;

-- Create enough tables to trigger GEQO (default threshold is 12)
CREATE TABLE t1 (id int); CREATE TABLE t2 (id int); CREATE TABLE t3 (id int);
CREATE TABLE t4 (id int); CREATE TABLE t5 (id int); CREATE TABLE t6 (id int);
CREATE TABLE t7 (id int); CREATE TABLE t8 (id int); CREATE TABLE t9 (id int);
CREATE TABLE t10 (id int); CREATE TABLE t11 (id int); CREATE TABLE t12 (id int);
CREATE TABLE t13 (id int);

-- 1. Force GEQO on
SET geqo = on;
SET geqo_threshold = 12;

-- 2. Run a massive join. Verify if advice is generated.
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13
WHERE t1.id = t2.id AND t2.id = t3.id AND t3.id = t4.id AND t4.id = t5.id
  AND t5.id = t6.id AND t6.id = t7.id AND t7.id = t8.id AND t8.id = t9.id
  AND t9.id = t10.id AND t10.id = t11.id AND t11.id = t12.id AND
t12.id = t13.id;

--3. SET pg_plan_advice.advice = 'JOIN_ORDER(t13 (t5 (t12 (t1 (t6 (t9
(t11 (t10 (t2 (t7 (t4 (t8 t3))))))))))))';
--4. Run Query again

Supplied Plan Advice:
   JOIN_ORDER(t13 (t5 (t12 (t1 (t6 (t9 (t11 (t10 (t2 (t7 (t4 (t8
t3)))))))))))) /* matched, failed */
 Generated Plan Advice:
   JOIN_ORDER(t13 (t5 (t12 (t8 t9 t1 t10 t3 t4 t6 t7 t2 t11))))
   NESTED_LOOP_PLAIN(t9 t1 t10 t3 t4 t6 t7 t2 t11)
   HASH_JOIN((t1 t2 t3 t4 t6 t7 t8 t9 t10 t11) (t1 t2 t3 t4 t6 t7 t8 t9 t10 t11
    t12) (t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12))
   SEQ_SCAN(t13 t5 t12 t8 t9 t1 t10 t3 t4 t6 t7 t2 t11)
   NO_GATHER(t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12 t13)

Thanks
Ajay

---

From: Robert Haas <[email-masked]>
Date: Thu, 12 Feb 2026 08:08:16 -0500

> algorithm never attempts the specific join path requested.

Seems expected. It is bad if using GEQO results in a crash or if the
advice cause the expected outcome when the path is considered, but if
the randomness of GEQO causes it not to consider the path the user
wants, then the user either needs to stop using GEQO, or use
less-strict plan advice, or just understand that this kind of outcome
is a possibility.

Thanks,

---

From: Robert Haas <[email-masked]>
Date: Thu, 12 Feb 2026 14:00:22 -0500

> advice cause the expected outcome when the path is considered, but if

sorry, this should have said "doesn't cause"

---

From: Robert Haas <[email-masked]>
Date: Wed, 18 Feb 2026 14:57:58 -0500

On Tue, Feb 10, 2026 at 6:03 PM Robert Haas <[email-masked]> wrote:
> I have committed those, as well as 0001 and 0002. Here's v15.

Here's v16. As a note, my biggest need right now is for some code
review. Testing help is, of course, also still very welcome.

What I have done in this version is run the test_plan_advice machinery
with the pg_plan_advice.feedback_warnings=on and try to fix as many of
the resulting failures as I could. The biggest revelation that I had
as a result of doing that is that it's really unfortunate for this
patch that get_relation_info_hook is only called for RTE_RELATION
baserels. I was somewhat aware of this problem before, but I thought
that it might be a non-critical thing that could be fixed at some
indefinite future point, maybe years in the future. However, this
testing convinced me that this could not really be postponed if I
wanted the patch set to actually work as intended. It's true that
there's nothing to control in terms of scan method for something like
a subquery scan, but you can want to control the placement of or
prevent the occurrence of Gather or Gather Merge nodes in join
problems that involve non-relation RTEs, and there's really no way to
do that with the existing hook placement.

But it seems like there's a pretty easy solution, which is to move the
hook up from get_relation_info to its sole caller, build_simple_rel.
Patch 0002 implements this and has a commit message explaining how
code that currently uses get_relation_info_hook can be modified to
work with the replacement, buld_simple_rel_hook.

The rest of the patch set is as before, except for the main patch, now
0003, which has the following additional changes:

- It has been updated to use build_simple_rel_hook rather than
get_relation_info_hook.

- I have corrected some order of operations problems in
pgpa_walk_recursively(). The previous coding code deliver incorrect
results when an elided SubqueryScan should have been considered the
only RTI-bearing plan node beneath a Gather or Gather Merge.

- I have adjusted pgpa_planner_apply_joinrel_advice() to fix a problem
where GATHER() or GATHER_MERGE() advice could be falsely reported as
conflicting with PARTITIONWISE() advice.

- I have adjusted pgpa_plan_walker() and pgpa_walk_recursively() to
fix a problem with Gather or Gather Merge nodes together with
partitionwise aggregation, where the generated plan advice was
sometimes incorrect.

- I have done some further work on the documentation. Specifically, I
added a list of known limitations, or at least the start of one; I
expanded the discussion of what "matched" and "partially matched"
mean; I expanded the discussion of how NO_GATHER works; and I and
changed some references from "table" to "relation".

- Comment improvements.

As you might or might not guess, a lot of these bug fixes and
documentation updates were indirect consequences of the replacement of
get_relation_info_hook with buld_simple_rel_hook. Once I made that
change, it fixed some problems, but revealed others which I then had
to fix or document. However, some of these fixes are just cleaning up
other things revealed by running test_plan_advice with
pg_plan_advice.feedback_warnings=on.

Even though I have been running test_plan_advice locally with
pg_plan_advice.feedback_warnings=on, I have not enabled that in this
version of the patch set. That's because that still causes 1 test
failure, due to self-join elimination doing something that perhaps it
shouldn't. More about that later, after I've further analyzed it.

---

From: Alexandra Wang <[email-masked]>
Date: Wed, 18 Feb 2026 22:52:57 -0800

Hi Robert,

> patch is now 0002, and has the following changes since the last

I'm about halfway through reviewing 0002 and only now finally
understand what 0001 is doing. 0001 looks good to me.

This took me a bit to work through. I'll send more feedback by the end
of this week.

Best,
Alex

---

From: Robert Haas <[email-masked]>
Date: Thu, 19 Feb 2026 14:46:38 -0500

> understand what 0001 is doing. 0001 looks good to me.

Great!

> This took me a bit to work through. I'll send more feedback by the end

Yeah, it's a huge patch. Thanks for having a look through it. I look
forward to your feedback.

Here's v17. I have committed what was previously 0004 (which Richard
reviewed on another thread) so that is dropped from this version. In
addition, this version fixes the last issue that was causing failures
running test_plan_advice with pg_plan_advice.feedback_warnings=on. In
my previous email, I said that I thought the failure in question might
be caused by some undesirable coding in the self-join elimination
code, but that turned out to be incorrect. Instead, the problem was
that pga_identifier.c was using planenr_rt_fetch() in some places, and
it needs to use rt_fetch() everywhere. That's because
planner_rt_fetch() consults simple_rte_array, which both self-join
elimination and join removal can mutate, while rt_fetch() uses the
Query's rtable directly, and that never changes. Since we're trying to
create stable identifiers, "never changes" is what we want. In
addition to changing that, I also cleaned up a few XXX comments.

---

From: Alexandra Wang <[email-masked]>
Date: Mon, 23 Feb 2026 17:10:02 -0800

Hi Robert,

I've been reviewing the v17-0003 patch and learned a lot along the
way.  The design is well thought out and the implementation looks
solid.  I only have a few minor comments and questions.

While reading the patch, I noticed several places where we infer
planner intent from currently observed plan shapes (e.g., Gather
projection, Agg used for semijoin uniqueness, Append elision, etc.),
which I quote below:

In pgpa_decompose_join():
> /*
>  * 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.
>  */

In pgpa_descend_any_unique():
> 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);
> }

In pgpa_walk_recursively():
>  * 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;
> }

In pgpa_build_scan():
> /*
>  * If setrefs processing elided an Append or MergeAppend node that had
>  * only one surviving child, it might be a partitionwise operation,
>  * but then this is either a setop over subqueries, or a partitionwise
>  * operation (which might be a scan or a join in reality, but here we
>  * don't care about the distinction and consider it simply a scan).
>  *
>  * A setop over subqueries, or a trivial SubQueryScan that was elided,
>  * is an "ordinary" scan i.e. one for which we need to generate advice
>  * because the planner has not made any meaningful choice.
>  */
> 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;

All of the interpretations above look reasonable, but they made me
wonder how modules like this detect planner-behavior drift over time
if we add new plan node types, add or change plan shapes, or even a
new debug GUC.

I realize this isn't specific to this patch, but I'm curious whether
something like monitoring a possible set of parent-child plan node
patterns has ever been considered, to make structural changes easier
to notice when those invariants stop holding. I don't think this is
actionable for this patch, but I'm just recording the thought here
while reading.

---

I ran the pg_plan_advice module tests as well as test_plan_advice, and
everything passed locally.

I also noticed that several tests in pg_plan_advice are not listed in
the Makefile. Specifically, they are semijoin.sql, syntax.sql,
prepared.sql, and local_collector.sql. I ran them manually, and they
all passed.

---

In pgpa_walk_recursively():
> /*
>  * 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);
> list_delete_ptr(walker->future_query_features, plan);
> break;
> }
> }

Should this be deleting "qf" instead of "plan"? Right now the list is
not emptied after the plan tree walk as intended.

---

In pgpa_decompose_join():
> if (found_any_outer_gather &&
> ...
> if (found_any_inner_gather &&

"found_any_{outer,inner}_gather" should be dereferenced.

---

In pgpa_trim_shared_advice():
> /* Don't leave stale pointers around. */
> memset(&chunk_array[remaining_chunk_count], 0,
>   sizeof(pgpa_shared_advice_chunk *)
>   * (total_chunk_count - remaining_chunk_count));

Should the element size be "sizeof(dsa_pointer)", consistent with the
preceding memmove()?

---

In pg_plan_advice_advice_check_hook():
> if (error != NULL)
> {
> GUC_check_errdetail("Could not parse advice: %s", error); //
> return false;
> }
>
> MemoryContextSwitchTo(oldcontext);
> MemoryContextDelete(tmpcontext);
>
> return true;

If an error occurs, do we leak the memory context? Should we also
switch and delete memory context before returning false?

---

In pg_get_collected_local_advice(), we skip rows when:

> if (!member_can_set_role(userid, ca->userid))
> continue;

Do we need to do the same check for pg_get_collected_shared_advice()?

---

In pgpa_planner_append_feedback(), "StringInfoData buf;" is unused.

---

The "ExplainState *explain_state" field and the "MemoryContext
trove_cxt" field in "struct pgpa_planner_state" are both unused.

Best,
Alex

---

From: Robert Haas <[email-masked]>
Date: Thu, 26 Feb 2026 08:55:56 -0500

Thanks, Alex, for the review.

> projection, Agg used for semijoin uniqueness, Append elision, etc.),
[....]
> if we add new plan node types, add or change plan shapes, or even a

In my personal opinion, this is one of the really key issues for this
patch and deserves more discussion, but it feels like it's been very
difficult to get any senior hackers to pay attention to this patch.
I'm not sure whether that's because it's a huge patch, or they just
don't want to touch it with a ten foot pole, or they're just busy with
their own work, but it's a little hard as the patch author to know how
reasonable the decisions you've made are until you hear what other
people think of them.

In terms of keeping things working, I think that test_plan_advice has
a pretty good chance of catching future breakage. If somebody adds a
new planner optimization that breaks the plan tree walk that
pg_plan_advice does, they should also be adding test cases for it. If
test_plan_advice can't generate plan advice any more after that
change, the patch will cause a test failure there, or if it can
generate plan advice but that plan advice doesn't properly apply to
planning, again a test failure will result. But as to whether
test_plan_advice is a good enough test or whether more things need to
be added for people to feel comfortable, well, that's a question.
Also, there's the issue that even if test_plan_advice is good enough,
or can be made so, that still potentially burdens future patch authors
with the task of updating pg_plan_advice for whatever they do. That's
not unique to this patch, of course; any patch that expands the
capabilities of PostgreSQL in a new direction has that issue to some
degree. To take a few examples from my own past work, consider wait
events or parallel query. But there's still a judgement call to made
about whether the patch adds too much burden for what we get out of
it. Personally, I think that some kind of user control over the
planner is one of the really big things that experienced PostgreSQL
users want, and therefore I'm inclined to believe it's worth it. But
most people are in favor of their own patches, so that's not really
surprising.

> actionable for this patch, but I'm just recording the thought here

One thing I've been thinking about is that we might want to consider
annotating some planner nodes with a little more information so that
pg_plan_advice doesn't have to infer quite as much. For example, if we
annotated Agg nodes with their purpose (regular agg, eager agg,
partitionwise agg, semijoin uniqueness) and their relid set,
pg_plan_advice would have an easier time figuring out what's going on
and could throw errors if unexpected things happen instead of
delivering wrong results, possibly silently. I have avoided doing that
kind of thing so far because I wanted to keep the core changes as
small as possible, but there's an argument that I've taken too much
risk there. One of the other things that bugs me about this patch is
that if it turns out that there are things in the advice generation
machinery that are not fully reliable, it may not be possible to fix
those without an ABI break. That is, we can fix any problem we might
have by having the core planner publish more information, but we've
been trying really hard lately to avoid changing structure definitions
or function signatures in minor releases, and I can imagine that it
wouldn't be that hard for pg_plan_advice to be shown to have some
problem that can't be fixed any other way. So what happens if I commit
this and then such a bug shows up? Do we compromise on ABI stability,
or do we just leave the bug unfixed for the life time of that release
and fix it in the next major release, or what? I suppose it's hard to
know in the abstract, but this is another area where getting some
opinions from other committers would be awfully useful.

> prepared.sql, and local_collector.sql. I ran them manually, and they

Yeah, thanks, I actually also found this myself while doing some
refactoring, shortly before you sent this email. Will fix.

> not emptied after the plan tree walk as intended.

Yeah, I think you're right. I don't think that mistake breaks
anything, it just means the list grows needlessly long, but I'll fix
it.

> "found_any_{outer,inner}_gather" should be dereferenced.

Yikes, good catch.

> Should the element size be "sizeof(dsa_pointer)", consistent with the

Yes, thanks.

> switch and delete memory context before returning false?

I think this should be mostly harmless. The parent context, at least
in most cases, shouldn't be anything that long-lived, and when it's
destroyed, it will take this context with it. I think there are some
cases where the parent context can be longer-lived, but they shouldn't
happen often enough for this to become a significant issue. On the
other hand, there's also no reason that I can see not to tighten this
up. I changed it like this:

    if (error != NULL)
        GUC_check_errdetail("Could not parse advice: %s", error);

    return (error == NULL);

> Do we need to do the same check for pg_get_collected_shared_advice()?

Hmm, yeah, probably a good idea.

...
> trove_cxt" field in "struct pgpa_planner_state" are both unused.

Thanks, deleted this things.

---

From: Robert Haas <[email-masked]>
Date: Fri, 27 Feb 2026 17:46:21 -0500

> 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.

First, I realized that it might be confusing to have the collector
interface as part of pg_plan_advice, because for most of what
pg_plan_advice does, you didn't need the extension, but for that part,
you did. So, I broke that part out into its own extension, now called
pg_collect_advice, and put it into a separate patch. I think this is
conceptually cleaner: pg_plan_advice is just the core functionality of
generating and enforcing advice, and anything else is a separate
extension that logically sits on top of that core functionality. This
also has the advantage that you can decide to make the core
functionality available without any chance of somebody getting access
to the collector, if desired.

Second, I also added a third contrib module called pg_stash_advice.
This uses the same hook that I previously added for test_plan_advice,
but unlike that module, this one's not just a test. It lets you set up
an "advice stash" which is basically a query_id->advice_string hash
table. If you then set pg_stash_advice.stash_name to the name of your
advice stash, it will do a lookup into that hash table every time a
query is planned and, if the query ID is found, it will do the
planning with the corresponding advice string. What I think is
particularly cool about this is that it shows that you can really use
that hook to apply advice on the fly in any way you want. I suspect
that query ID matching will be suitable for a lot of use cases, but
you could have a similar module that matches on query text or does
anything else that you want as long as an advice string pops out at
the end. It shows that the core pg_plan_advice infrastructure is
pluggable. So this is both something that I think a lot of people will
find useful all on its own, and also a design pattern that people can
copy and adapt.

Finally, I did a lot of minor cleanups. I originally planned to try to
include a full list in this email, but as the number of fixes got
larger, I eventually realized that would get incredibly tedious, even
for me. An awful lot of what got fixed was just straightforward typos,
but there were also some comments where the wording was garbled and
didn't really make sense, or where I thought the comment should be
longer or shorter than it actually was, or where I used the wrong data
type in the code but in a way that didn't actually break anything.
There are a few fixes to the code, but they're all very minor stuff
that I'm not sure has any real-world consequences, although it might
so it's good to fix it, but the overwhelming bulk of it is cosmetic.

Thanks,

---

From: "David G. Johnston" <[email-masked]>
Date: Fri, 27 Feb 2026 18:16:10 -0700

> 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.

David J.
diff --git a/contrib/pg_plan_advice/README b/contrib/pg_plan_advice/README
index 0b888fd82f2..9178e2e8c3b 100644
--- a/contrib/pg_plan_advice/README
+++ b/contrib/pg_plan_advice/README
@@ -26,6 +26,8 @@ Advice tags can also be applied to groups of relations; for example,
 relation identifier "baz" as well as to the 2-item list containing
 "bletch" and "quux".
 
+XXX: probably should remove "you", the document seems to prefer "the user"
+
 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
@@ -37,16 +39,17 @@ 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.
+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 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,
+regard to whether it appears to be good advice or bad advice. If you
+provide bad advice, whether derived from a previously-generated plan
+or manually written, you 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.
+conservatively, only when truly required to avoid problems, will be a more
+successful strategy than applying it with a broad brush, but you are free
+to experiment with whatever strategies you think best.
 
 Relation Identifiers
 ====================
@@ -54,7 +57,7 @@ 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:
+three ways that the same relation alias can occur multiple times:
 
 1. It can appear in more than one subquery.
 
@@ -75,13 +78,13 @@ 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.
+but user-written advice may 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,
+For many scan types, 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
@@ -94,8 +97,8 @@ 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
-the given scan type would be used for foo as a single relation and also the
+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.
 
@@ -115,14 +118,14 @@ 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,
+partitionwise joins can create independent 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
+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,
@@ -135,17 +138,18 @@ 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 advice that uses curly braces, indicating an absence 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.
+saying how that join must be performed (including 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
+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
@@ -157,22 +161,21 @@ 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
+loop, rather than specifically on the inner side, but this definition is
+problematic 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
+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 definition compels join strategy advice to also impose
 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,
+be impossible to put bar beneath the inner side of a Hash Join. Thus it's
+reasonable to consider deleting the
+join order advice and only apply 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).
@@ -188,11 +191,9 @@ 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.
+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).))
 
 Semijoin Uniqueness
 ===================
@@ -236,7 +237,7 @@ 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
+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.
 
@@ -253,7 +254,7 @@ 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
+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.
 
diff --git a/doc/src/sgml/pgplanadvice.sgml b/doc/src/sgml/pgplanadvice.sgml
index 817bafa21c6..c962e565a3a 100644
--- a/doc/src/sgml/pgplanadvice.sgml
+++ b/doc/src/sgml/pgplanadvice.sgml
@@ -10,7 +10,7 @@
  <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
+  advice" domain specific language (DSL). 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>
@@ -18,9 +18,9 @@
  <para>
   Note that, since the planner often makes good decisions, overriding its
   judgement 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
+  underlying data changes, the planner will make different decisions in order to
+  preserve good performance with the new data. If the plan advice overrides
+  these new decisions, 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>

---

From: "David G. Johnston" <[email-masked]>
Date: Sun, 1 Mar 2026 21:10:52 -0700

> 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.
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> [ ... ])

@@ -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>

---

From: "David G. Johnston" <[email-masked]>
Date: Mon, 2 Mar 2026 15:09:12 -0700

On Sun, Mar 1, 2026 at 9:10 PM David G. Johnston <[email-masked]>
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
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.
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>

---

From: "David G. Johnston" <[email-masked]>
Date: Mon, 2 Mar 2026 16:11:17 -0700

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

> On Sun, Mar 1, 2026 at 9:10 PM David G. Johnston <
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.
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>

---

From: Jakub Wartak <[email-masked]>
Date: Tue, 3 Mar 2026 12:42:13 +0100

On Fri, Feb 27, 2026 at 11:46 PM Robert Haas <[email-masked]> wrote:
> Here's v18. [..]
[..]
> find useful all on its own, and also a design pattern that people can

Hi Robert, I'm glad that you posted this. I've thought this is so important that
I've sligthly added more descriptive subject as this is NEW and easily to miss
out as it was burried in the second paragraph and most folks probably would miss
that this $thread is now includes code for transparent SQL plan
overrides (most known
as baselines or SQL plan management) - it has many names, but I havent come
across "stash" as one of them, so people could miss it too easily.

1. First thought is that I found it quite surprising, we now have 3 modules
and it's might cause confusion and lack of consistency:
- 3 can to be in shared_preload_libraries (pg_stash_advice,
pg_plan_advice, pg_collecti_advice)
- 2 others can use CREATE EXTENSION (pg_stash_advice, pg_collect_advice), but
  "create extension pg_plan_advice;" fails
so maybe they all should behave the same as people (including me) won't read
the docs and just blindly add it here and there and issue CREATE EXTENSION,
but it's going to be hard to remember for which ones (?) So we need more
consistency?

2. Should pgca always duplicate entries like that? (each call gets new entry?)
Shouldn't it just update collection_time for identical calls of
userid/dbid/queryid?

postgres=# select * from pg_get_collected_shared_advice();
 id | userid | dbid |       queryid       |        collection_time
   |                      query                      |
 advice
----+--------+------+---------------------+-------------------------------+-------------------------------------------------+-------------------------------------------
  0 |     10 |    5 | 1069089066624131207 | 2026-03-03
09:47:40.322294+01 | select * from pg_get_collected_shared_advice(); |
NO_GATHER(pg_get_collected_shared_advice)
(1 row)

postgres=# select * from pg_get_collected_shared_advice();
 id | userid | dbid |       queryid       |        collection_time
   |                      query                      |
 advice
----+--------+------+---------------------+-------------------------------+-------------------------------------------------+-------------------------------------------
  0 |     10 |    5 | 1069089066624131207 | 2026-03-03
09:47:40.322294+01 | select * from pg_get_collected_shared_advice(); |
NO_GATHER(pg_get_collected_shared_advice)
  1 |     10 |    5 | 1069089066624131207 | 2026-03-03
09:47:41.983973+01 | select * from pg_get_collected_shared_advice(); |
NO_GATHER(pg_get_collected_shared_advice)
(2 rows)

It floods like that for everything, most visible with couple of independent
starts of pgbench -M prepared. Maybe I'm wrong , but I don't think it worked
like that earlier before refactor?

3. The good news is that I have managed to finally overrride SQL plans
completley transparently using set of those modules for online pgbench runs.
However I found couple of issues.

3a. Because query_id each time will be different for every query even
in standard
pgbench mode, I've managed to achieve it only using prepared statements.
Realistically we'll need some way of modular matching not just on query_id OR
query_id should be changed how it being calculcated or maybe by some other means
(argument is: not all users/apps use prepared statements):
- at least some minimal query jumbling, part of me belives that e.g. code from
  62d712ecfd940 / pgss's generate_normalized_query() should be more reusable
  across other extensions, and then why not just use it here?
- maybe it should: ignore uppercase vs lowercase, removal of extra whitespaces
- maybe it should: ignore schema names (in multi-tenant shops)
- maybe it should: remove SQL comments (/*+  xxx */) or newlines (as there are
  client-side libraries that annotate SQL queries by putting comments to locate
  e.g. app source code location/origin)
- maybe we even should have some regexp

I'm not buying argument that it will make something slower, because :
a) this is on-demand loaded module for those who want it
b) it exists purely to avoid way bigger performance problems in the first place

3b. When performing manual testing using PREPARE s1 / EXECUTE s1, the SQL plans
are effective out of the box. Below I'm intentionally downgrading runtime
performance and that works:

postgres=# PREPARE p1 (int) AS UPDATE pgbench_accounts SET abalance =
abalance + $1 WHERE aid = $2;
PREPARE
postgres=# \timing on
Timing is on.
postgres=# EXECUTE p1(42, 42);
UPDATE 1
Time: 8.241 ms

-- making it slow, queryid from explain (verbose), same session:
postgres=# select pg_set_stashed_advice('abc456',
-9041336337128051785, 'SEQ_SCAN(pgbench_accounts)');
 pg_set_stashed_advice
-----------------------

(1 row)

Time: 0.995 ms
postgres=# show pg_stash_advice.stash_name ;
 pg_stash_advice.stash_name
----------------------------
 abc456
(1 row)

Time: 0.366 ms
-- OK, that's effective (up from 8ms)
postgres=# EXECUTE p1(42, 42);
UPDATE 1
Time: 448.415 ms

3c. However for pgbench -M prepared, such online plan alterations are
strangley not effective. Even creating new stash under different name, setting
GUC, and pg_reloading_conf is not effective too for hundreths of seconds of
pgbench.

3d. ... however restarting restarting pgbench helps and the session
changes plan (and thus
performance characteristics). When using manual way of reproducing
this more like:

sess1=# PREPARE p1 (int) AS UPDATE pgbench_accounts SET abalance =
abalance + $1 WHERE aid = $2;
PREPARE
sess1=# EXECUTE p1(42, 42);
UPDATE 1
sess1=# \timing on
Timing is on.
sess1=# EXECUTE p1(42, 42);
UPDATE 1
Time: 8.823 ms

-- now from 2nd session
sess2=# select pg_set_stashed_advice('abc456', -9041336337128051785,
'SEQ_SCAN(pgbench_accounts)');

-- .. it was NOT effective, and still fast, but it should be slow:
sess1=# EXECUTE p1(42, 42);
UPDATE 1
Time: 8.781 ms
postgres=#

However doing it from sess2 sometimes, makes it often faster effective:
postgres=# select pg_create_advice_stash('xyz123');
postgres=# select pg_set_stashed_advice('xyz123',
-9041336337128051785, 'SEQ_SCAN(pgbench_accounts)');
postgres=# alter system set pg_stash_advice.stash_name TO 'xyz123';
postgres=# select pg_reload_conf();

but apparently that was still not solving the pgbench case problem.
So it doesn't seem to be deterministic when the new plan is applied (?)

3e. As this was pretty concerning, I've repeated the pgbench -M prepared
excercise and I've figured out that I could achieve effect that I wanted (
boucing between fast <-> slow immediatley) by injecting call via gdb on that
backend to InvalidateSystemCaches(). Kind of brute-force, but only
then it worked
instantly the pgbench backend started using new "stash" immediatley). Question
should or should not it immediatley effective and have you got any idea why
there is such difference in behaviour between pgbench and psql? Should we
investigate it further? I think, we should?

4. The familiy of pg_*stash*() functions could return some ::bool result instead
of void. Like true? (it's usage "feeling" that leaves one wondering if
the command
was effective or not, e.g. to get consistency with let's say pg_reload_conf())

5. QQ: will pg_stash_advice persist the stashes one day?

6. Any idea for better name than 'stash' ? :) It's some new term that is for
sure not wildly recognized. Some other used name across industry: plan
stability,
advice freeze, plan freeze, plan force, baselines, plan management, force plan,
force paths, SQL plan optimization profiles, query store (MSSQL).

7. I saw you have written that in docs to be careful about memory use, but
wouldn't be it safer if that maximum memory for pgca (when collecting in shared
with almost infinite limite) would be still subject to like let's say 5% of s_b
(or any other number here)?

8. I'm wondering if we should apply standard PostgreSQL case insensitivity
rule (e.g. like for relations) for those stashes? On one front we ignore case
sensitivty for objects, one another this is GUC so perhaps it is OK (I
feel it is
ok, but I wanted to ask).

9. If IsQueryIdEnabled() is false (even after trying out to use 'auto')
shouldn't this module raise warning when pg_stash_advice.stash_name != NULL?

> [..altered sequence],
[..]
> First I realized that it might be confusing to have the collector
> pg_collect_advice, [..]

10. I'm was here mainly for v18-0007 (pg_stash_advice), but this still looks
like some small bug to me (minmax matching in v18-0003):

create table t1  as select * from generate_series(1, 100000) as id;
create unique index t1_pk on t1 (id);
analyze t1;

-- OK "matched"
postgres=# set pg_plan_advice.advice to 'INDEX_ONLY_SCAN(t1@minmax_1
public.t1_pk)';
SET
postgres=# explain (plan_advice, costs off) select max(id) from t1;
                        QUERY PLAN
-----------------------------------------------------------
 Result
   Replaces: MinMaxAggregate
   InitPlan minmax_1
     ->  Limit
           ->  Index Only Scan Backward using t1_pk on t1
                 Index Cond: (id IS NOT NULL)
 Supplied Plan Advice:
   INDEX_ONLY_SCAN(t1@minmax_1 public.t1_pk) /* matched */
 Generated Plan Advice:
   INDEX_ONLY_SCAN(t1@minmax_1 public.t1_pk)
   NO_GATHER(t1@minmax_1)
(11 rows)

Manual SET, wont work and it is failed (which is OK)

postgres=# set pg_plan_advice.advice to 'SEQ_SCAN(t1)';
SET
postgres=# explain (plan_advice, costs off) select max(id) from t1;
                        QUERY PLAN
----------------------------------------------------------
 Result
   Replaces: MinMaxAggregate
   InitPlan minmax_1
     ->  Limit
           ->  Index Only Scan Backward using t1_pk on t1
                 Index Cond: (id IS NOT NULL)
 Supplied Plan Advice:
   SEQ_SCAN(t1) /* matched, failed */
 Generated Plan Advice:
   INDEX_ONLY_SCAN(t1@minmax_1 public.t1_pk)
   NO_GATHER(t1@minmax_1)

However with below SEQ_SCAN is applied/matched, but marked as failed
(so bug?):

postgres=# set pg_plan_advice.advice to 'SEQ_SCAN(t1@minmax_1)';
SET
postgres=# explain (plan_advice, costs off) select max(id) from t1;
                  QUERY PLAN
-----------------------------------------------
 Aggregate
   ->  Seq Scan on t1
 Supplied Plan Advice:
   SEQ_SCAN(t1@minmax_1) /* matched, failed */
 Generated Plan Advice:
   SEQ_SCAN(t1)
   NO_GATHER(t1)

-J.

---

From: Robert Haas <[email-masked]>
Date: Tue, 3 Mar 2026 13:55:47 -0500

> but it's going to be hard to remember for which ones (?) So we need more

That's a possible point of view, but the flip side is that then it's
all-or-nothing. I think pg_plan_advice is a toolkit that bridge as a
rather large gulf between the theoretical power to walk plan trees and
use pgs_mask to modify behavior and the practicalities of actually
doing so. I think we would be well-advised to think of pg_plan_advice
as a core of functionality that, some day, we might even think of
moving into core, and pg_collect_advice and pg_stash_advice as things
that can be built around that core. I think it's important to have
demonstrations available that show that pg_plan_advice is not a
"sealed hood" that you must accept exactly as it is, but rather a
toolkit that you can do a variety of things with. pg_collect_advice
and pg_stash_advice are examples of what you can do, but many
variations on those same themes are possible.

> Shouldn't it just update collection_time for identical calls of

To make it do that, we would need a completely different way of
storing entries, in order to avoid a linear scan of all collected data
for each new query. It would be a completely different module. I think
we probably want to have something like that, but it isn't this. See
previous point.

> like that earlier before refactor?

The functionality has not changed since I first posted this. It's only
had bug fixes and now some refactoring.

> (argument is: not all users/apps use prepared statements):

Sure. See once again the first point in this email, about how this is
a toolkit. You can write your own module and use
pg_plan_advice_add_advisor to add your own hook that does the matching
any way it likes and returns any string it wants. Maybe someone will
add that functionality to pg_stash_advice at some point in the future,
or maybe somebody will add a separate module that does it, either in
contrib or on PGXN or wherever they want to publish it. There are lots
of nice things here that people could want to have, but none of that
is going to happen in time for PostgreSQL 19. The very most we're
going to get is what I've already posted.

It's way too late to think about reinventing query jumbling or even
designing a new way to do query matching. Feature freeze is in just
over a month. The question is whether it's reasonable to commit what
I've already got, not whether it's reasonable to start a whole new
subproject.

> strangley not effective.
[....]
> performance characteristics).

One thing to keep in mind is that the whole point of -M prepared is to
avoid replanning. My guess is that what you're seeing here is that
only replan when something invalidates the plan, which seems more like
a feature than a bug, although possibly somebody is going to want a
way to force an invalidation when the stashed advice changes. That's a
pretty big hammer, though. Does this behavior go away if you use -M
extended?

> was effective or not, e.g. to get consistency with let's say pg_reload_conf())

I'm up for changing the return value to something more informative if
there's an informative thing to return, but if there is no information
being returned, I stand by the choice of void as most appropriate.

> 5. QQ: will pg_stash_advice persist the stashes one day?

I have no current plan to implement that, but who knows?

> force paths, SQL plan optimization profiles, query store (MSSQL).

I'm up to change this if there's a consensus on what it should be
called, but that will require more than 1 vote. I picked stash because
I wanted to capture the idea of sticking something in a place where
you could grab it easily. Personally, my main critique of that name is
that this particular module matches by query ID and you could instead
match by $WHATEVER, so what would you call the next module that also
stashes advice strings but keyed in some slightly different way?
(Likewise, what do you call the next alternative to pg_collect_advice
that uses different collection criteria or storage?)

Honestly, I was hoping and sort of expecting to get a lot more naming
feedback back when I first posted this. Don't call it advice, call it
hints or guidance or tweaks! Don't write HASH_JOIN(foo), write
JOIN_METHOD(foo, HASH_JOIN)! And so forth. On the one hand, the fact
that I didn't get that back then has made this less work, and I'm
still pretty happy with my choices. Furthermore, it's still not too
late to do some renaming if we agree on what that should look like. At
the same time, I don't particularly want to get into a fun and
exciting game of design-by-committee-under-time-pressure. As a
Demotivators poster said many years ago, "none of us is as dumb as all
of us." I freely admit that some of my naming and some of my design
decisions are almost certainly suboptimal, but at the same time, it
would be easy to underestimate the amount of thought that I've put
into some of the naming, so I'd only like to change it if we have a
pretty clear consensus that any given counter-proposal is better than
what I did. I especially do not want to go change it and then have to
change it all again because somebody else shows up with a new opinion.

> (or any other number here)?

It depends on what you mean by safer. One thing that is unsafe is
running the computer out of memory. However, another thing that is
unsafe is getting the wrong answer. I felt that limiting the number of
queries was a good compromise. If you limit it to a certain amount of
memory usage, then (1) it's much harder to actually figure out whether
we're over budget and how much we need to free to be under budget and
(2) I suspect it's also harder to be sure whether you've set the limit
high enough to not lose any of the data you intended to retain. I
think that a fixed limit like 5% of shared_buffers would be, bluntly,
completely nuts. There is no reason to suppose that the amount of
memory someone is willing to use to store advice has anything to do
with the size of shared_buffers. If you have 100 sessions running
pgbench and you enabled the local collector with that limit in all of
them, you would be using 500% of shared_buffers in advice-storage
memory and that would probably take down the server. On the other
hand, if you're using the shared collector with shared_buffers=2GB,
that is only 10MB, which might easily be too small to store all the
data you care about even on a test system.

The reason I set the limits in terms of number of queries was that (1)
it's easy for the code to figure out whether it's over the limit and
easy for it to reduce utilization to the limit and (2) I thought that
people using this were likely to have a better idea how many queries
they wanted to collect than how many MB/GB they wanted to collect. Of
course, (1) could be solved with enough effort and (2) could be
wrong-headed on my part, but if somebody wanted this changed, it would
have been nice to know that a few months ago when I first posted this
rather than now.

> ok, but I wanted to ask).

Yeah, I'm not sure. I wondered about this, too.

> shouldn't this module raise warning when pg_stash_advice.stash_name != NULL?

I think the bar for printing a warning on every single query is
extremely high. Doing that just because of a questionable
configuration setting doesn't seem like a good idea.

> postgres=# explain (plan_advice, costs off) select max(id) from t1;

I think this is just out of scope for now. It's documented that we
don't have a way of controlling aggregation behavior at present. Here
again, we can do more things in future releases, but it's too late to
add more to the scope for this release. There's still plenty of time
to fix bugs, but this isn't a bug. We'd need a whole lot of new
machinery to prevent this sort of thing, including new core hooks and
new syntax. Here again, we have the freedom to decide that I chose the
scope wrongly and that this is therefore not shippable as is, but I do
not think there is time to significantly expand the scope at this
point.

---

From: "David G. Johnston" <[email-masked]>
Date: Tue, 3 Mar 2026 12:28:16 -0700

> not think there is time to significantly expand the scope at this
I mostly get why specifying an index that doesn't exist as part of advice,
alongside a target relation, produces "matched" along with "inapplicable"
and "failed".

INDEX_SCAN(f no_such_index) /* matched, inapplicable, failed */

But less understandable is why a failure to match a subplan-qualified
target produces "matched" when the subplan doesn't appear.

Maybe we need to do something like:

relname - matches anywhere in the plan tree
relname@somewhere - only looks at "somewhere" for matches; absence of
somewhere results in "not matched" (the expected feedback for the
advice/query combination above).

This would necessitate needing some way to specify "top-level" after the
"@" symbol - leaving it blank would suffice.  I haven't worked through
"directly at the named subplan" versus "the named subplan and any
descendants"...

If keeping the status quo the existing behavior should be documented. The
existing wording for not matched; "or it may occur if the relevant portion
of the query was not planned," seems to be the one that covers this case.

David J.

---

From: Robert Haas <[email-masked]>
Date: Tue, 3 Mar 2026 22:36:13 -0500

On Tue, Mar 3, 2026 at 2:28 PM David G. Johnston
<[email-masked]> wrote:
> I mostly get why specifying an index that doesn't exist as part of advice, alongside a target relation, produces "matched" along with "inapplicable" and "failed".
>
> But less understandable is why a failure to match a subplan-qualified target produces "matched" when the subplan doesn't appear.
>

Because there's not a way to control aggregation behavior at present,
you can't directly conrol whether t1 or t1@minmax_1 appears in the
winning plan. This case is "matched" because we saw t1@minmax_1 get
planned, but it's "failed" because the non-minmax strategy then won on
cost.

Aside: This is actually an instance of a very common problem into
which I have already invested an enormous amount of time and energy.
Suppose that for some operation X (which might be a scan or a join or
any other kind of thing) you tell the planner that you want it to use
strategy S. If strategy S is not what was going to be chosen anyway,
you've just increased the cost of X. Unsurprisingly, the planner's
response to that is very often to choose not to perform X at all.
Otherwise, it ends up looking like the planner has just made an
end-run around the advice. For example, HASH_JOIN(X) says "put X on
the inner side of a hash join". Obviously, this means that when we see
a join with X as the inner rel, we should disallow all join strategies
other than hash join. Less obviously, it also means that when we see a
join with X as the outer rel, we shouldn't allow *any* join
strategies. If you don't do that, you get a problem very similar to
what you're complaining about here. The difference is that join
control is supported by the patch set, and so I have put in the work
to avoid instances of this problem that occur in that case, and
aggregate control is not, so I haven't.

> relname@somewhere - only looks at "somewhere" for matches; absence of somewhere results in "not matched" (the expected feedback for the advice/query combination above).

This would not work in general; "anywhere in the plan tree" is too
broad a scope, and it would be easy to construct an example where it
falsely matches an unrelated part of the query. I think that the
solution here probably looks more like letting the user write
AGGREGATE_PLAIN(something) or AGGREGATE_MINMAX(something), but to make
it really work, we would need to figure out what the "something"
should be, and also remember to account for partitionwise aggregation,
eager aggregation, partial aggregation, hashed aggregation, sorted
aggregation, plain aggregation, and mixed aggregation, some of which
can be combined with some of the others but not all of the others, and
then we would need to add design and implement hooks in core to allow
that advice to be enforced, and then we would need to get
pg_plan_advice to properly accept the syntax and enforce it and also
be able to generate the syntax from a finished plan tree, and then
document all that and add tests. I think that work is worth doing,
but, again, I think it would be a much better idea to spend the next
few weeks trying to figure out whether it makes sense to commit what
I've got, and perform any necessarily stabilization of that
functionality, than to keep saying "hey, maybe you should radically
expand the scope." It's probably a six-month project to get all that
working, and we don't have that time for v19.

> If keeping the status quo the existing behavior should be documented. The existing wording for not matched; "or it may occur if the relevant portion of the query was not planned," seems to be the one that covers this case.

That's definitely relevant here, although these specific examples seem
to have more to do with "failed" than "not matched".

---

From: "David G. Johnston" <[email-masked]>
Date: Tue, 3 Mar 2026 21:11:38 -0700

On Tuesday, March 3, 2026, Robert Haas <[email-masked]> wrote:

> winning plan.

Ok, that’s what I was missing here, it saw the subplan in its options but
the winning plan didn’t include it.  So “matched/failed” may produce a plan
where the target having been matched isn’t actually visible to the user.

Maybe add a note like this to pg_plan_advice:

Generated advice is produced to a high level of specificity without knowing
what limitations the advice interpreter has in applying that advice.
Therefore, generated advice targets may later fail for no other reason than
cost-based decisions resulted in the originally chosen plan to no longer be
chosen.  The planner will still likely see the original target on a now
losing plan and thus the advice feedback will report matched even when the
winning plan does not include the specific target.

David J.

---

From: Jakub Wartak <[email-masked]>
Date: Wed, 4 Mar 2026 10:50:40 +0100

On Tue, Mar 3, 2026 at 7:56 PM Robert Haas <[email-masked]> wrote:

Hi Robert,

> variations on those same themes are possible.

This is micro-thing, feel free to ignore, but well I was after something much
more easy: `CREATE EXTENSION pg_plan_advice`to be no-op without any failure
even if it doesnt provide any views or fucntions right now (so empty
share/extension/pg_plan_advice.control and -1.0.sql) or at least some dummy
function, just so that CREATE EXTENSION pg_plan_advice wouldn't fail.
This is nothing technical, it's just sharp rough edge for user (but
technically sound), that 2 are deployed with CREATE EXTENSION but 3rd one is
not. I just think that if we put 3 into shared_preload_libraries then I won't
have to think for which ones to exec CREATE EXTENSION (I would just blindly do
it for all and the error wouldn't make somebody unhappy that something is
potentially not working because CREATE EXTENSION is not for it) - it's pure
user-focused usability feedback.

[..]
> had bug fixes and now some refactoring.

OK, I misremembered it then.

> pretty big hammer, though. Does this behavior go away if you use -M

Yes with -M extended it is instant. I have found also that with
-M prepared I can do simple one-time `analyze pgbench_accounts` (when changing
SEQ_SCAN <-> INDEX_SCAN for that table) and that is also enough for
the backend to immediatley see (and react to) to what's in the active
configured stash even for future changes without further ANALYZEs.
Not sure if pg_stash_advice needs a function to flush-force all backends,
so the plans are 100% effecitve, as apparently we seem to have ANALYZE
already, but it is not that obvious that one might want to use it.

If there would be such function to gurantee, we probably wouldn't see
complaints like 'I have done this and session still is using old plan'.

> I have no current plan to implement that, but who knows?

OK, so perhaps docs for pg_create_advice_stash() and pg_set_stashed_advice()
should mention those 'stashes' are not persistent across restarts. Without
this I can already hear scream of some users from the future that they
applied advice, it fixed problem and after some time it disappeared (In
other RDBMS it is persistent, so users coming from there might have such
expectations).

Also related, we could also mention that this information is not replicated to
standbys as this is in-memory only.

> change it all again because somebody else shows up with a new opinion.

Well IMHO all the rest naming in pretty great shape and I think that
pg_[collect|plan]_advice are great names too. It's just that `stash`
keyword doesn't ring a bell to me at all that `pg_stash_advice` is
related in any way to online/transparent/runtime plan modification and
can be used to alter plans for other backends. Something like
`pg_deploy_advice` or `pg_apply_advice` would be more in line with the
other two, but perhaps it's just me..

> have been nice to know that a few months ago when I first posted this

I havent reported it earlier, well because there was little sense playing with
collection implementation much earlier if we didn't have pinning of the plans
itself (and the whole debate query_id also seemed pointless back then). Well
anyway, it is much like work_mem danger, but work_mem is about well specific
amount of memory (* N plan nodes) so it's just easier to put safer value there.

How about if we would just measure (estimate) with some small table number of
entries vs memory used and put that into the docs?, so users are wary that
they shouldn't just blidnly set it to high value? E.g. with
1000 local limit I get ~280kB for pg_collect_advice context and with 1000000
local limit I've got it to ~50MB and stopped looking further (it was still
growing). Itself that's not terrible but higher values with lots of backends
might cause huge memory pressure (OOMs).

Or maybe other idea: is there is possibility of making GUCs like
local_collection_limits/local_collector settable only using
SET/SET LOCAL, but not global? I mean what's the point of having being
able to collect locally system-wide when realistically I cannot
pull it back from backend-local memory? (and this removes the danger
of multple backends goind wild with memory together).

Below maybe useful for others - if they trying this, if testing performance
please ensure you do not have Casserts as with Casserts enabling higher
local_collection_limits are barley usable:

progress: 137.0 s, 1516.9 tps, lat 0.659 ms stddev 0.174, 0 failed
progress: 138.0 s, 1469.1 tps, lat 0.680 ms stddev 0.174, 0 failed
progress: 139.0 s, 1050.0 tps, lat 0.952 ms stddev 0.206, 0 failed
progress: 140.0 s, 922.0 tps, lat 1.085 ms stddev 0.194, 0 failed
[..]
progress: 143.0 s, 664.0 tps, lat 1.504 ms stddev 0.377, 0 failed
progress: 144.0 s, 595.0 tps, lat 1.681 ms stddev 0.386, 0 failed
progress: 145.0 s, 531.0 tps, lat 1.883 ms stddev 0.402, 0 failed
[..]
progress: 159.0 s, 260.0 tps, lat 3.840 ms stddev 0.607, 0 failed
progress: 160.0 s, 271.0 tps, lat 3.702 ms stddev 0.447, 0 failed

resetting it back to 0 or disabling local collector and reloading won't
fix it, backend needs to re-establish connection. Even with just 10000
local collection limit it just gets down from ~1500 tps to 900 tps.
It's seems the impact on CPU coming to be from exec_simple_query()
-> finish_xact_command() -> MemoryContextCheck() -> AllocSetCheck(),
so memory context validation that have literally nothing to do with
with this patch (other than it using a lot of memory in those scenarios)

> configuration setting doesn't seem like a good idea.

Why on every single query? I'm thinking that this should bail out in pgca&pgsa
during initialization of those modules. What's the point of those modules
if queryid is always 0? (I'm assuming somebody has compue_query_id=off and
still loaded those modules).

[..]
> don't have a way of controlling aggregation behavior at present. [..]

Oh ok, I've completley missed "pgplanadvice-limitations" SGML section about
aggregates, so sure - fair limitation.

BTW: the good news is that I haven't seen a single crash when throwing
wild stuff on it or having strange ideas at pg_stash_advice usage.

-J.

---

From: Robert Haas <[email-masked]>
Date: Wed, 4 Mar 2026 08:37:33 -0500

On Tue, Mar 3, 2026 at 11:11 PM David G. Johnston
<[email-masked]> wrote:
> Ok, that’s what I was missing here, it saw the subplan in its options but the winning plan didn’t include it.  So “matched/failed” may produce a plan where the target having been matched isn’t actually visible to the user.
>
> Generated advice is produced to a high level of specificity without knowing what limitations the advice interpreter has in applying that advice.  Therefore, generated advice targets may later fail for no other reason than cost-based decisions resulted in the originally chosen plan to no longer be chosen.  The planner will still likely see the original target on a now losing plan and thus the advice feedback will report matched even when the winning plan does not include the specific target.

There is doubtless lots of room for improvement in the documentation,
but this specific text doesn't seem like a good idea, because it's not
true in general. The whole point of test_plan_advice is to ensure that
generated advice targets *don't* fail, and with all the patches
applied, I get a clean run where every single advice target generated
by the main regression test suite can be successfully applied back to
the plan that generated it. I think the real issue here is that Jakub
has found a case where fiddling with the scan method causes the
optimal aggregation method to change, and the resulting weird behavior
stems from the fact that aggregation control is not supported. What
you want to be able to do is nail down the aggregation behavior first,
and then it would be clear whether to provide scan advice for t1 or
for t1@minmax_1.

But I just don't see what the big deal is here. One thing you can do
is provide advice for both t1 and t1@minmax_1 and then the advice will
be followed for whichever one appears in the final plan. The other
will be marked as failed, but you can decide to just ignore that.
Possibly you could even indirectly control whether the minmax path is
selected by advising an inefficient strategy for the one you don't
want to be be picked and a great strategy for the one you do want to
be picked. Also, I've personally never run into a real-world case
where the planner made a bad decision about whether to use a minmaxagg
or a regular agg, which is why all of my development time and effort
went into scans and joins and related topics where I do regularly see
things go wrong.

So at the risk of repeating myself: It is not in general true that you
should expect your advice to randomly not work, but that happens in
this case because aggregation control is not supported, which is a
documented limitation.

---

From: Robert Haas <[email-masked]>
Date: Wed, 4 Mar 2026 09:17:25 -0500

> user-focused usability feedback.

Absolutely not. We don't do that for other contrib modules that don't
require SQL definitions (e.g. auth_delay, auto_explain,
basebackup_to_shell, passwordcheck) and I don't think we should start
now. I agree that it can be confusing for users that there is a
distinction between extensions and loadable modules, but the right
solution to that problem is to educate the users. I think people who
do know how things are supposed to work would find it quite irritating
to be handed an extension that doesn't actually install anything.
Also, I have some hope that at some point in the future, we might
decide to ingest pg_plan_advice into core while keeping the other
things as contrib modules, and if that ever happens, it will be a lot
easier if it's only a loadable module and has no extension associated
with it.

> complaints like 'I have done this and session still is using old plan'.

True, but causing a system-wide cache invalidation can also create
quite a performance hit. I'm not quite sure what the best thing to do
here is. I can see an argument that changing the advice for a certain
query ought to invalidated stored plans for that query, but I don't
think our invalidation infrastructure is capable of doing anything
that targeted. Just invalidating everything seems pretty heavy-handed,
but maybe it will turn out to be the right answer. I think we should
wait for more people to play with this before deciding anything.

> other RDBMS it is persistent, so users coming from there might have such

I mean, there is already text about this in the very first paragraph
of the pg_stash_advice documentation. Perhaps you're saying you think
that information needs to be mentioned in multiple places in that
documentation, or in a different place than where it's currently
mentioned, but I'm slightly suspicious that you might not have
actually read what I already wrote.

> other two, but perhaps it's just me..

I don't think it's just you. I originally named this pg_auto_advice.
However, the naming problem that then ran into was: what do you call
the containers that actually store the advice? I ended up calling them
"advice stores". But I didn't like that very much, because now the
name of the module (pg_auto_advice) and the name of the objects that
it creates (advice stores) are not obviously related. Moreover, I had
an unpleasant hunch that people weren't going to like the idea of
using "store" as a noun. So I tried to think of a word that I could
use for both the name of the extension and the name of the objects,
and stash is what I came up with. Your suggestions here are in much
the same vein as my original idea, but I would argue that they also
have the same problem: we're not going to call the named advice
containers "advice deploys" or "advice applies". Now, perhaps there's
an argument that those names don't have to match, but I think it makes
it a lot less confusing that they do.

> might cause huge memory pressure (OOMs).

It depends a lot on the length of the query strings and the advice
strings. I wondered whether there was some point in having a setting
to collect only the advice string and not the query string, but in the
end I feel like pg_collect_advice is very much 1.0 software. It works,
but it's fairly primitive. It definitely shouldn't be confused with
industrial-strength, battle-grade, Teflon-hardened code. The problem
from my point of view is that not only are we short on time for this
release, but I do not feel like I know what the requirements for
something better really are. I think your suggestions so far --
deduplication, better memory control -- are very reasonable ideas, but
I think it's hard to know what is really important until more people
get their hands on this, try it out, and then (probably) complain
about it. I think between the people on this thread, or even between
you and I, we could easily come up with a list of 30, maybe even 50,
possible improvements to pg_collect_advice, but I am not at all
confident we'd correctly guess which 3-5 of those were going to be
most important to users. I think we just need to wait for more data
before deciding how to evolve this.

> of multple backends goind wild with memory together).

The GUC infrastructure doesn't support this.

> with this patch (other than it using a lot of memory in those scenarios)

Sounds like you are running a debug build with asserts enabled, which
you probably don't want to do if you're trying to benchmark.

> still loaded those modules).

That seems like a tremendous overreaction, given that (1) it might
cause the server to fail to start and (2) compute_query_id can be
changed at any time.

> wild stuff on it or having strange ideas at pg_stash_advice usage.

I don't think there are too many crashes left (famous last words).
Here's a list of things I'm currently most worried about,
approximately in order starting from the most worrying:

* Maybe the advice-generation stuff doesn't correctly analyze all
possible plan trees, esp. cases not covered by our core regression
tests.
* Maybe the stuff that uses DSM isn't careful enough and can therefore
cause server-lifespan memory leaks in some scenario.
* Maybe I haven't got the security model right and some aspect of what
I've done there is CVE-worthy.
* Maybe advice application is broken in some cases in a way that can't
be fixed without additional core changes.

I'd be very grateful for review targeting any of these areas.

Thanks,

---

From: Robert Haas <[email-masked]>
Date: Wed, 4 Mar 2026 10:17:37 -0500

On Fri, Feb 27, 2026 at 8:16 PM David G. Johnston
<[email-masked]> 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:

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.

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.

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.

---

From: "David G. Johnston" <[email-masked]>
Date: Wed, 4 Mar 2026 08:44:42 -0700

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

> 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?

> 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.

---

From: Robert Haas <[email-masked]>
Date: Wed, 4 Mar 2026 11:20:45 -0500

On Wed, Mar 4, 2026 at 10:45 AM David G. Johnston
<[email-masked]> 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,

---

From: "David G. Johnston" <[email-masked]>
Date: Fri, 6 Mar 2026 07:46:24 -0700

On Wed, Mar 4, 2026 at 9:20 AM Robert Haas <[email-masked]> 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?
>
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.

---

From: Robert Haas <[email-masked]>
Date: Tue, 10 Mar 2026 10:55:26 -0400

On Fri, Mar 6, 2026 at 9:47 AM David G. Johnston
<[email-masked]> 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,

---

From: Robert Haas <[email-masked]>
Date: Thu, 12 Mar 2026 13:15:39 -0400

On Tue, Mar 10, 2026 at 10:55 AM Robert Haas <[email-masked]> 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,

---

From: Alexandra Wang <[email-masked]>
Date: Thu, 12 Mar 2026 16:45:49 -0700

Hi Robert,

> 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

---

From: Lukas Fittl <[email-masked]>
Date: Fri, 13 Mar 2026 01:38:25 -0700

Hi Robert,

> 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.

---

From: Alexander Lakhin <[email-masked]>
Date: Sat, 14 Mar 2026 14:00:00 +0200

Hello Robert,

> there's a big difference between lab-testing and real world usage, so

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

---

From: Zsolt Parragi <[email-masked]>
Date: Sat, 14 Mar 2026 15:06:04 +0000

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.

---

From: Robert Haas <[email-masked]>
Date: Mon, 16 Mar 2026 12:52:55 -0400

> 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.

---

From: Robert Haas <[email-masked]>
Date: Mon, 16 Mar 2026 13:11:16 -0400

> Could please look at this?

Thanks. Proposed fix attached.

---

From: Robert Haas <[email-masked]>
Date: Mon, 16 Mar 2026 13:34:59 -0400

> +EXTRA_INSTALL = contrib/pg_plan_advice

Thanks, will fix.

> "pg_stash.advice_stash" should be "pg_stash_advice.stash_name".

Thanks, will fix.

> 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.

> 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.

---

From: Robert Haas <[email-masked]>
Date: Mon, 16 Mar 2026 16:51:09 -0400

On Fri, Mar 13, 2026 at 4:39 AM Lukas Fittl <[email-masked]> 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.

---

From: Lukas Fittl <[email-masked]>
Date: Mon, 16 Mar 2026 16:25:05 -0700

> parsing, and think about whether that leads to any other changes or

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.

> 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.

> 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.

> 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

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.

> results for interesting things. You can't do that with the local

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

---

From: Zsolt Parragi <[email-masked]>
Date: Tue, 17 Mar 2026 05:06:08 +0000

> 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.

---

From: Robert Haas <[email-masked]>
Date: Tue, 17 Mar 2026 09:44:34 -0400

> present - assuming we can sort out the performance/etc. aspects of

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.

> 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.

> 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.)

> Postgres docs, it could fit the wiki as a cheatsheet of sorts.

Yeah, that sounds nice.

> 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.

---

From: Robert Haas <[email-masked]>
Date: Tue, 17 Mar 2026 13:44:06 -0400

> 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.

---

From: Robert Haas <[email-masked]>
Date: Tue, 17 Mar 2026 17:45:52 -0400

> 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.

---

From: Robert Haas <[email-masked]>
Date: Wed, 18 Mar 2026 13:07:54 -0400

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?

---

From: Lukas Fittl <[email-masked]>
Date: Wed, 18 Mar 2026 11:59:46 -0700

> 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.

> 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.

> 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

---

From: Robert Haas <[email-masked]>
Date: Wed, 18 Mar 2026 18:19:31 -0400

On Wed, Mar 18, 2026 at 4:44 PM Tom Lane <[email-masked]> 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.

---

From: Robert Haas <[email-masked]>
Date: Wed, 18 Mar 2026 18:34:29 -0400

On Wed, Mar 18, 2026 at 6:26 PM Tom Lane <[email-masked]> wrote:
> 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.

---

From: Robert Haas <[email-masked]>
Date: Wed, 18 Mar 2026 18:57:59 -0400

On Wed, Mar 18, 2026 at 6:43 PM Tom Lane <[email-masked]> 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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 19 Mar 2026 12:54:43 -0400

> 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.

> 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.

> list of strings attached to each - that'd save the nested DefElem and

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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 19 Mar 2026 13:17:04 -0400

On Thu, Mar 19, 2026 at 12:46 PM Tom Lane <[email-masked]> 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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 19 Mar 2026 16:38:23 -0400

> > except that I don't agree that we should be running it in the

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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 19 Mar 2026 17:33:37 -0400

On Thu, Mar 19, 2026 at 4:12 PM Matheus Alcantara
<[email-masked]> 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=Submit

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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 19 Mar 2026 18:19:17 -0400

On Thu, Mar 19, 2026 at 6:03 PM Tom Lane <[email-masked]> 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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 19 Mar 2026 19:11:18 -0400

On Thu, Mar 19, 2026 at 6:43 PM Tom Lane <[email-masked]> 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.

---

From: Robert Haas <[email-masked]>
Date: Fri, 20 Mar 2026 12:06:40 -0400

On Thu, Mar 19, 2026 at 1:02 PM Tom Lane <[email-masked]> 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.

---

From: Robert Haas <[email-masked]>
Date: Sat, 21 Mar 2026 09:13:59 -0400

On Thu, Mar 19, 2026 at 4:38 PM Robert Haas <[email-masked]> wrote:
> schnauzer's got a failure that looks like this:
>

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.

---

From: Robert Haas <[email-masked]>
Date: Tue, 24 Mar 2026 16:47:12 -0400

> I think the fix is as attached.

The new test in that version was exactly backwards. I have corrected
that issue and committed this.

---

From: Robert Haas <[email-masked]>
Date: Tue, 24 Mar 2026 17:09:51 -0400

> those ideas before committing to a precise course of action. More

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.

---

From: Lukas Fittl <[email-masked]>
Date: Wed, 25 Mar 2026 16:59:06 -0700

Hi Robert,

> 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

> updates pg_plan_advice to implement DO_NOT_SCAN().

For 0002:

I think that overall looks like a good refactoring, with two minor notes:

> index fee88904760..70139ff42be 100644
> @@ -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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 26 Mar 2026 09:55:53 -0400

> 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.

> 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.

> This could use pg_nextpower2_32 on the rel->relid instead of the

OK, will change that also, and then also commit this one.

> 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.

> 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.

---

From: Matheus Alcantara <[email-masked]>
Date: Thu, 26 Mar 2026 11:30:40 -0300

On 26/03/26 10:55, Robert Haas wrote:
> 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

---

From: Robert Haas <[email-masked]>
Date: Thu, 26 Mar 2026 10:37:15 -0400

> 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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 26 Mar 2026 13:20:21 -0400

> pretty tired of seeing these test_plan_advice failures already.

Done.

> 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.

> kidding ourselves a little bit.

Here's a new version, where I've replaced alternative_root by
alternative_plan_name, serving the same function.

---

From: Lukas Fittl <[email-masked]>
Date: Thu, 26 Mar 2026 12:51:29 -0700

Hi Robert,

> 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

---

From: Mark Dilger <[email-masked]>
Date: Thu, 26 Mar 2026 16:20:49 -0700

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.

-- 

*Mark Dilger*

---

From: Robert Haas <[email-masked]>
Date: Thu, 26 Mar 2026 19:25:57 -0400

> 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.

> 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.

> 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.

---

From: Jakub Wartak <[email-masked]>
Date: Fri, 27 Mar 2026 09:00:03 +0100

On Thu, Mar 26, 2026 at 6:20 PM Robert Haas <[email-masked]> 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.

---

From: Lukas Fittl <[email-masked]>
Date: Fri, 27 Mar 2026 01:31:11 -0700

> 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

---

From: Robert Haas <[email-masked]>
Date: Fri, 27 Mar 2026 08:55:41 -0400

On Fri, Mar 27, 2026 at 2:00 AM Alexander Lakhin <[email-masked]> 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.

---

From: Robert Haas <[email-masked]>
Date: Fri, 27 Mar 2026 09:08:30 -0400

> 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.

> apparently only reads it?

Good question. Actually, couldn't both of those loops use a shared lock only?

---

From: Robert Haas <[email-masked]>
Date: Fri, 27 Mar 2026 10:20:30 -0400

>   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,

---

From: Robert Haas <[email-masked]>
Date: Fri, 27 Mar 2026 11:44:15 -0400

> 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.

> 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,

---

From: Lukas Fittl <[email-masked]>
Date: Sun, 29 Mar 2026 11:58:39 -0700

Hi Robert,

> duplicating that across hundreds (or, gulp, thousands) of backends is

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.

> 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.

> than the cache, the cache hit ratio will go down the tubes and so will

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.

> 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.

> 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?

> 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.

> from among the available options (or other options they may have in

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

---

From: Robert Haas <[email-masked]>
Date: Mon, 30 Mar 2026 10:53:30 -0400

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.

> "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.

> 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?

> 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.

---

From: Lukas Fittl <[email-masked]>
Date: Tue, 31 Mar 2026 19:25:04 -0700

> 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.

> 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.

> thinking we would document that pg_get_dsm_registry_allocations() can

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 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

---

From: Lukas Fittl <[email-masked]>
Date: Tue, 31 Mar 2026 23:33:50 -0700

> 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)

> very large memory allocation through a large advice string, which is

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

---

From: Robert Haas <[email-masked]>
Date: Thu, 2 Apr 2026 12:15:09 -0400

> 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!

---

From: Robert Haas <[email-masked]>
Date: Thu, 2 Apr 2026 12:44:32 -0400

> 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.

---

From: Tom Lane <[email-masked]>
Date: Thu, 02 Apr 2026 19:43:33 -0400

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

---

From: Robert Haas <[email-masked]>
Date: Thu, 2 Apr 2026 22:08:51 -0400

Thanks for the report.

> 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).

> indexes to choose.  Will test_plan_advice cope if the transient one

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.

---

From: Robert Haas <[email-masked]>
Date: Thu, 2 Apr 2026 22:15:36 -0400

On Thu, Apr 2, 2026 at 12:15 PM Robert Haas <[email-masked]> wrote:
> So here's v24, also dropping pg_collect_advice.

That version didn't actually pass CI. Here's v25.

---

From: Robert Haas <[email-masked]>
Date: Fri, 3 Apr 2026 13:13:47 -0400

> 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,

---

From: Tom Lane <[email-masked]>
Date: Fri, 03 Apr 2026 14:20:32 -0400

> 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.

> thoughts as well. If none, I'll proceed as described above and update

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

---

From: Robert Haas <[email-masked]>
Date: Fri, 3 Apr 2026 20:14:49 -0400

> 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?

---

From: Tom Lane <[email-masked]>
Date: Fri, 03 Apr 2026 23:14:46 -0400

Robert Haas <[email-masked]> writes:
> ... But I also feel like if we've only seen one buildfarm
> Or even just how often this one happens.

Reasonable point.

> running the tests serially is the best solution in a month, can't we

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

---

From: Lukas Fittl <[email-masked]>
Date: Sat, 4 Apr 2026 01:11:25 -0700

> 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:

> new file mode 100644
> index 00000000000..ec60552a447
> --- /dev/null
> @@ -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

---

From: Andrei Lepikhov <[email-masked]>
Date: Sat, 4 Apr 2026 11:34:35 +0200

> ... but what we can do about this post-freeze seems pretty

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-masked]
[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

---

From: Robert Haas <[email-masked]>
Date: Sat, 4 Apr 2026 14:42:19 -0400

> 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.

---

From: Andrei Lepikhov <[email-masked]>
Date: Sat, 4 Apr 2026 23:02:37 +0200

> 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.

---

From: Robert Haas <[email-masked]>
Date: Sat, 4 Apr 2026 18:52:21 -0400

> 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.

---

From: Andrei Lepikhov <[email-masked]>
Date: Sun, 5 Apr 2026 09:57:13 +0200

> 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?

---

From: Alexander Lakhin <[email-masked]>
Date: Sun, 5 Apr 2026 11:00:00 +0300

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

---

From: Alexander Lakhin <[email-masked]>
Date: Sun, 5 Apr 2026 15:00:00 +0300

> 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

---

From: Robert Haas <[email-masked]>
Date: Mon, 6 Apr 2026 08:47:30 -0400

> 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.

---

From: Andrei Lepikhov <[email-masked]>
Date: Mon, 6 Apr 2026 15:22:04 +0200

> 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.

---

From: Andres Freund <[email-masked]>
Date: Mon, 6 Apr 2026 09:56:23 -0400

Hi,

On 2026-04-04 23:02:37 +0200, Andrei Lepikhov 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.
> 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

---

From: Robert Haas <[email-masked]>
Date: Mon, 6 Apr 2026 10:01:52 -0400

> future - think about Citus or TimescaleDB optimisations, for example.

Definitely possible.

> 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.

---

From: Peter Geoghegan <[email-masked]>
Date: Mon, 6 Apr 2026 10:14:33 -0400

> I suggest you rethink your approach of engaging with others.

+1

-- 
Peter Geoghegan

---

From: Andrei Lepikhov <[email-masked]>
Date: Mon, 6 Apr 2026 17:11:15 +0200

> 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.

---

From: Robert Haas <[email-masked]>
Date: Mon, 6 Apr 2026 15:52:51 -0400

> 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.

---

From: Robert Haas <[email-masked]>
Date: Mon, 6 Apr 2026 16:15:21 -0400

> 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.

---

From: Robert Haas <[email-masked]>
Date: Tue, 7 Apr 2026 10:17:12 -0400

> 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.

---

From: Robert Haas <[email-masked]>
Date: Tue, 7 Apr 2026 17:21:19 -0400

> ... but what we can do about this post-freeze seems pretty

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,

---

From: Tom Lane <[email-masked]>
Date: Tue, 07 Apr 2026 18:05:47 -0400

Robert Haas <[email-masked]> writes:
> 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

No strong opinion about that.  Certainly waiting a couple of weeks
to gather more data seems reasonable.

			regards, tom lane

---

From: Nathan Bossart <[email-masked]>
Date: Wed, 8 Apr 2026 09:49:01 -0500

> to gather more data seems reasonable.

I am only 1/3 of the RMT, but I am fine with the plan as stated.

-- 
nathan

---

From: Melanie Plageman <[email-masked]>
Date: Wed, 8 Apr 2026 12:18:11 -0400

> 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

---

From: Robert Haas <[email-masked]>
Date: Mon, 13 Apr 2026 12:01:25 -0400

> 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,

---

From: Alexander Lakhin <[email-masked]>
Date: Tue, 14 Apr 2026 21:00:01 +0300

13.04.2026 19:01, Robert Haas wrote:
> 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

Best regards,
Alexander

---

From: Tender Wang <[email-masked]>
Date: Wed, 15 Apr 2026 18:30:01 +0800

> 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

---

From: Robert Haas <[email-masked]>
Date: Wed, 15 Apr 2026 15:46:58 -0400

> 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.

---

From: Tender Wang <[email-masked]>
Date: Thu, 16 Apr 2026 09:45:05 +0800

> 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

---

From: Robert Haas <[email-masked]>
Date: Fri, 17 Apr 2026 15:00:05 -0400

> 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.

---

From: Michael Paquier <[email-masked]>
Date: Wed, 10 Jun 2026 09:21:25 +0900

> 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.

> 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