pg_plan_advice: Invent DO_NOT_SCAN(relation_identifier).

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

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

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

To make this work, we reuse a bunch of the machinery that previously
existed for the purpose of ensuring that we build the same set of
relation identifiers during planning as we do from the final
PlannedStmt. In the process, this commit slightly weakens the
cross-check mechanism: before this commit, it would fire whenever
the pg_plan_advice module was loaded, even if pg_plan_advice wasn't
actually doing anything; now, it will only engage when we have some
other reason to create a pgpa_planner_state. The old way was complex
and didn't add much useful test coverage, so this seems like an
acceptable sacrifice.

Discussion: http://postgr.es/m/CA+TgmoYuWmN-00Ec5pY7zAcpSFQUQLbgAdVWGR9kOR-HM-fHrA@mail.gmail.com
Reviewed-by: Lukas Fittl <lukas@fittl.com>
This commit is contained in:
Robert Haas 2026-03-26 17:09:57 -04:00
parent 26255a3207
commit 6455e55b0d
15 changed files with 589 additions and 126 deletions

View file

@ -109,6 +109,13 @@ Bitmap heap scans currently do not allow for an index specification:
BITMAP_HEAP_SCAN(foo bar) simply means that each of foo and bar should use
some sort of bitmap heap scan.
There is a special DO_NOT_SCAN() advice tag which says that a certain
relation shouldn't be scanned at all. This is used to control which of
two choices is selected when an AlternativeSubPlan is resolved, and
whether or not a MinMaxAggPath is chosen. Control over upper planner
behavior is generally out-of-scope at the moment, but these cases had
to be handled to prevent test_plan_advice failures in the buildfarm.
Join Order Advice
=================

View file

@ -0,0 +1,158 @@
LOAD 'pg_plan_advice';
SET max_parallel_workers_per_gather = 0;
CREATE TABLE alt_t1 (a int) WITH (autovacuum_enabled = false);
CREATE TABLE alt_t2 (a int) WITH (autovacuum_enabled = false);
CREATE INDEX ON alt_t2(a);
INSERT INTO alt_t1 SELECT generate_series(1, 1000);
INSERT INTO alt_t2 SELECT generate_series(1, 100000);
VACUUM ANALYZE alt_t1;
VACUUM ANALYZE alt_t2;
-- This query uses an OR to prevent the EXISTS from being converted to a
-- semi-join, forcing the planner through the AlternativeSubPlan path.
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM alt_t1
WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
QUERY PLAN
-------------------------------------------------------------------
Seq Scan on alt_t1
Filter: ((ANY (a = (hashed SubPlan exists_2).col1)) OR (a < 0))
SubPlan exists_2
-> Seq Scan on alt_t2
Generated Plan Advice:
SEQ_SCAN(alt_t1 alt_t2@exists_2)
NO_GATHER(alt_t1 alt_t2@exists_2)
DO_NOT_SCAN(alt_t2@exists_1)
(8 rows)
-- We should be able to force either AlternativeSubPlan by advising against
-- scanning the other relation.
BEGIN;
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_t2@exists_1)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM alt_t1
WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
QUERY PLAN
-------------------------------------------------------------------
Seq Scan on alt_t1
Filter: ((ANY (a = (hashed SubPlan exists_2).col1)) OR (a < 0))
SubPlan exists_2
-> Seq Scan on alt_t2
Supplied Plan Advice:
DO_NOT_SCAN(alt_t2@exists_1) /* matched */
Generated Plan Advice:
SEQ_SCAN(alt_t1 alt_t2@exists_2)
NO_GATHER(alt_t1 alt_t2@exists_2)
DO_NOT_SCAN(alt_t2@exists_1)
(10 rows)
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_t2@exists_2)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM alt_t1
WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
QUERY PLAN
--------------------------------------------------------
Seq Scan on alt_t1
Filter: (EXISTS(SubPlan exists_1) OR (a < 0))
SubPlan exists_1
-> Index Only Scan using alt_t2_a_idx on alt_t2
Index Cond: (a = alt_t1.a)
Supplied Plan Advice:
DO_NOT_SCAN(alt_t2@exists_2) /* matched */
Generated Plan Advice:
SEQ_SCAN(alt_t1)
INDEX_ONLY_SCAN(alt_t2@exists_1 public.alt_t2_a_idx)
NO_GATHER(alt_t1 alt_t2@exists_1)
DO_NOT_SCAN(alt_t2@exists_2)
(12 rows)
COMMIT;
-- Now let's test a case involving MinMaxAggPath, which we treat similarly
-- to the AlternativeSubPlan case.
CREATE TABLE alt_minmax (a int) WITH (autovacuum_enabled = false);
CREATE INDEX ON alt_minmax(a);
INSERT INTO alt_minmax SELECT generate_series(1, 10000);
VACUUM ANALYZE alt_minmax;
-- Using an Index Scan inside of an InitPlan should win over a full table
-- scan.
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT min(a), max(a) FROM alt_minmax;
QUERY PLAN
------------------------------------------------------------------------------------------
Result
Replaces: MinMaxAggregate
InitPlan minmax_1
-> Limit
-> Index Only Scan using alt_minmax_a_idx on alt_minmax
Index Cond: (a IS NOT NULL)
InitPlan minmax_2
-> Limit
-> Index Only Scan Backward using alt_minmax_a_idx on alt_minmax alt_minmax_1
Index Cond: (a IS NOT NULL)
Generated Plan Advice:
INDEX_ONLY_SCAN(alt_minmax@minmax_1 public.alt_minmax_a_idx
alt_minmax@minmax_2 public.alt_minmax_a_idx)
NO_GATHER(alt_minmax@minmax_1 alt_minmax@minmax_2)
DO_NOT_SCAN(alt_minmax)
(15 rows)
-- Advising against the scan of alt_minmax at the root query level should
-- change nothing, but if we say we don't want either of or both of the
-- minmax-variant scans, the plan should switch to a full table scan.
BEGIN;
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT min(a), max(a) FROM alt_minmax;
QUERY PLAN
------------------------------------------------------------------------------------------
Result
Replaces: MinMaxAggregate
InitPlan minmax_1
-> Limit
-> Index Only Scan using alt_minmax_a_idx on alt_minmax
Index Cond: (a IS NOT NULL)
InitPlan minmax_2
-> Limit
-> Index Only Scan Backward using alt_minmax_a_idx on alt_minmax alt_minmax_1
Index Cond: (a IS NOT NULL)
Supplied Plan Advice:
DO_NOT_SCAN(alt_minmax) /* matched */
Generated Plan Advice:
INDEX_ONLY_SCAN(alt_minmax@minmax_1 public.alt_minmax_a_idx
alt_minmax@minmax_2 public.alt_minmax_a_idx)
NO_GATHER(alt_minmax@minmax_1 alt_minmax@minmax_2)
DO_NOT_SCAN(alt_minmax)
(17 rows)
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax@minmax_1)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT min(a), max(a) FROM alt_minmax;
QUERY PLAN
--------------------------------------------------------
Aggregate
-> Seq Scan on alt_minmax
Supplied Plan Advice:
DO_NOT_SCAN(alt_minmax@minmax_1) /* matched */
Generated Plan Advice:
SEQ_SCAN(alt_minmax)
NO_GATHER(alt_minmax)
DO_NOT_SCAN(alt_minmax@minmax_1 alt_minmax@minmax_2)
(8 rows)
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax@minmax_1) DO_NOT_SCAN(alt_minmax@minmax_2)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT min(a), max(a) FROM alt_minmax;
QUERY PLAN
--------------------------------------------------------
Aggregate
-> Seq Scan on alt_minmax
Supplied Plan Advice:
DO_NOT_SCAN(alt_minmax@minmax_1) /* matched */
DO_NOT_SCAN(alt_minmax@minmax_2) /* matched */
Generated Plan Advice:
SEQ_SCAN(alt_minmax)
NO_GATHER(alt_minmax)
DO_NOT_SCAN(alt_minmax@minmax_1 alt_minmax@minmax_2)
(9 rows)
COMMIT;
DROP TABLE alt_t1, alt_t2, alt_minmax;

View file

@ -270,7 +270,8 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
COMMIT;
-- We can force a primary key lookup to use a sequential scan, but we
-- can't force it to use an index-only scan (due to the column list)
-- or a TID scan (due to the absence of a TID qual).
-- or a TID scan (due to the absence of a TID qual). If we apply DO_NOT_SCAN
-- here, we should get a valid plan anyway, but with the scan disabled.
BEGIN;
SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
@ -313,6 +314,20 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
NO_GATHER(scan_table)
(8 rows)
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(scan_table)';
EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
QUERY PLAN
-------------------------------------------------
Index Scan using scan_table_pkey on scan_table
Disabled: true
Index Cond: (a = 1)
Supplied Plan Advice:
DO_NOT_SCAN(scan_table) /* matched, failed */
Generated Plan Advice:
INDEX_SCAN(scan_table public.scan_table_pkey)
NO_GATHER(scan_table)
(8 rows)
COMMIT;
-- We can forcibly downgrade an index-only scan to an index scan, but we can't
-- force the use of an index that the planner thinks is inapplicable.

View file

@ -53,6 +53,7 @@ tests += {
'bd': meson.current_build_dir(),
'regress': {
'sql': [
'alternatives',
'gather',
'join_order',
'join_strategy',

View file

@ -32,6 +32,8 @@ pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
{
case PGPA_TAG_BITMAP_HEAP_SCAN:
return "BITMAP_HEAP_SCAN";
case PGPA_TAG_DO_NOT_SCAN:
return "DO_NOT_SCAN";
case PGPA_TAG_FOREIGN_JOIN:
return "FOREIGN_JOIN";
case PGPA_TAG_GATHER:
@ -92,6 +94,10 @@ pgpa_parse_advice_tag(const char *tag, bool *fail)
if (strcmp(tag, "bitmap_heap_scan") == 0)
return PGPA_TAG_BITMAP_HEAP_SCAN;
break;
case 'd':
if (strcmp(tag, "do_not_scan") == 0)
return PGPA_TAG_DO_NOT_SCAN;
break;
case 'f':
if (strcmp(tag, "foreign_join") == 0)
return PGPA_TAG_FOREIGN_JOIN;

View file

@ -80,6 +80,7 @@ typedef struct pgpa_advice_target
typedef enum pgpa_advice_tag_type
{
PGPA_TAG_BITMAP_HEAP_SCAN,
PGPA_TAG_DO_NOT_SCAN,
PGPA_TAG_FOREIGN_JOIN,
PGPA_TAG_GATHER,
PGPA_TAG_GATHER_MERGE,

View file

@ -54,6 +54,8 @@ static void pgpa_output_simple_strategy(pgpa_output_context *context,
List *relid_sets);
static void pgpa_output_no_gather(pgpa_output_context *context,
Bitmapset *relids);
static void pgpa_output_do_not_scan(pgpa_output_context *context,
List *identifiers);
static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
Bitmapset *relids);
@ -156,6 +158,9 @@ pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
/* Emit NO_GATHER advice. */
pgpa_output_no_gather(&context, walker->no_gather_scans);
/* Emit DO_NOT_SCAN advice. */
pgpa_output_do_not_scan(&context, walker->do_not_scan_identifiers);
}
/*
@ -395,6 +400,36 @@ pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
appendStringInfoChar(context->buf, ')');
}
/*
* Output DO_NOT_SCAN advice for all relations in the provided list of
* identifiers.
*/
static void
pgpa_output_do_not_scan(pgpa_output_context *context, List *identifiers)
{
bool first = true;
if (identifiers == NIL)
return;
if (context->buf->len > 0)
appendStringInfoChar(context->buf, '\n');
appendStringInfoString(context->buf, "DO_NOT_SCAN(");
foreach_ptr(pgpa_identifier, rid, identifiers)
{
if (first)
first = false;
else
{
pgpa_maybe_linebreak(context->buf, context->wrap_column);
appendStringInfoChar(context->buf, ' ');
}
appendStringInfoString(context->buf, pgpa_identifier_string(rid));
}
appendStringInfoChar(context->buf, ')');
}
/*
* Output the identifiers for each RTI in the provided set.
*

View file

@ -164,11 +164,13 @@ static void pgpa_planner_feedback_warning(List *feedback);
static pgpa_planner_info *pgpa_planner_get_proot(pgpa_planner_state *pps,
PlannerInfo *root);
static inline void pgpa_ri_checker_save(pgpa_planner_state *pps,
PlannerInfo *root,
RelOptInfo *rel);
static void pgpa_ri_checker_validate(pgpa_planner_state *pps,
PlannedStmt *pstmt);
static inline void pgpa_compute_rt_identifier(pgpa_planner_info *proot,
PlannerInfo *root,
RelOptInfo *rel);
static void pgpa_compute_rt_offsets(pgpa_planner_state *pps,
PlannedStmt *pstmt);
static void pgpa_validate_rt_identifiers(pgpa_planner_state *pps,
PlannedStmt *pstmt);
static char *pgpa_bms_to_cstring(Bitmapset *bms);
static const char *pgpa_jointype_to_cstring(JoinType jointype);
@ -264,20 +266,10 @@ pgpa_planner_setup(PlannerGlobal *glob, Query *parse, const char *query_string,
}
}
#ifdef USE_ASSERT_CHECKING
/*
* If asserts are enabled, always build a private state object for
* cross-checks.
*/
needs_pps = true;
#endif
/*
* We only create and initialize a private state object if it's needed for
* some purpose. That could be (1) recording that we will need to generate
* an advice string, (2) storing a trove of supplied advice, or (3)
* facilitating debugging cross-checks when asserts are enabled.
* an advice string or (2) storing a trove of supplied advice.
*
* Currently, the active memory context should be one that will last for
* the entire duration of query planning, but if GEQO is in use, it's
@ -321,9 +313,16 @@ pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
pps = GetPlannerGlobalExtensionState(glob, planner_extension_id);
if (pps != NULL)
{
/* Set up some local variables. */
trove = pps->trove;
generate_advice_feedback = pps->generate_advice_feedback;
generate_advice_string = pps->generate_advice_string;
/* Compute range table offsets. */
pgpa_compute_rt_offsets(pps, pstmt);
/* Cross-check range table identifiers. */
pgpa_validate_rt_identifiers(pps, pstmt);
}
/*
@ -394,13 +393,6 @@ pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
lappend(pstmt->extension_state,
makeDefElem("pg_plan_advice", (Node *) pgpa_items, -1));
/*
* If assertions are enabled, cross-check the generated range table
* identifiers.
*/
if (pps != NULL)
pgpa_ri_checker_validate(pps, pstmt);
/* Pass call to previous hook. */
if (prev_planner_shutdown)
(*prev_planner_shutdown) (glob, parse, query_string, pstmt);
@ -408,35 +400,38 @@ pgpa_planner_shutdown(PlannerGlobal *glob, Query *parse,
/*
* Hook function for build_simple_rel().
*
* We can apply scan advice at this point, and we also use this as an
* opportunity to do range-table identifier cross-checking in assert-enabled
* builds.
*/
static void
pgpa_build_simple_rel(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *rte)
{
pgpa_planner_state *pps;
pgpa_planner_info *proot = NULL;
/* Fetch our private state, set up by pgpa_planner_setup(). */
pps = GetPlannerGlobalExtensionState(root->glob, planner_extension_id);
/* Save details needed for range table identifier cross-checking. */
/*
* Look up the pgpa_planner_info for this subquery, and make sure we've
* saved a range table identifier.
*/
if (pps != NULL)
pgpa_ri_checker_save(pps, root, rel);
{
proot = pgpa_planner_get_proot(pps, root);
pgpa_compute_rt_identifier(proot, root, rel);
}
/* If query advice was provided, search for relevant entries. */
if (pps != NULL && pps->trove != NULL)
{
pgpa_identifier rid;
pgpa_identifier *rid;
pgpa_trove_result tresult_scan;
pgpa_trove_result tresult_rel;
/* Search for scan advice and general rel advice. */
pgpa_compute_identifier_by_rti(root, rel->relid, &rid);
pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_SCAN, 1, &rid,
rid = &proot->rid_array[rel->relid - 1];
pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_SCAN, 1, rid,
&tresult_scan);
pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, &rid,
pgpa_trove_lookup(pps->trove, PGPA_TROVE_LOOKUP_REL, 1, rid,
&tresult_rel);
/* If relevant entries were found, apply them. */
@ -1626,6 +1621,8 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
pgpa_trove_entry *rel_entries,
Bitmapset *rel_indexes)
{
const uint64 all_scan_mask = PGS_SCAN_ANY | PGS_APPEND |
PGS_MERGE_APPEND | PGS_CONSIDER_INDEXONLY;
bool gather_conflict = false;
Bitmapset *gather_partial_match = NULL;
Bitmapset *gather_full_match = NULL;
@ -1636,16 +1633,18 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
Bitmapset *scan_type_indexes = NULL;
Bitmapset *scan_type_rel_indexes = NULL;
uint64 gather_mask = 0;
uint64 scan_type = 0;
uint64 scan_type = all_scan_mask; /* sentinel: no advice yet */
/* Scrutinize available scan advice. */
while ((i = bms_next_member(scan_indexes, i)) >= 0)
{
pgpa_trove_entry *my_entry = &scan_entries[i];
uint64 my_scan_type = 0;
uint64 my_scan_type = all_scan_mask;
/* Translate our advice tags to a scan strategy advice value. */
if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
if (my_entry->tag == PGPA_TAG_DO_NOT_SCAN)
my_scan_type = 0;
else if (my_entry->tag == PGPA_TAG_BITMAP_HEAP_SCAN)
{
/*
* Currently, PGS_CONSIDER_INDEXONLY can suppress Bitmap Heap
@ -1679,9 +1678,9 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
* INDEX_SCAN(a b.c) as non-conflicting if it happens that the only
* index named c is in schema b, but it doesn't seem worth the code.
*/
if (my_scan_type != 0)
if (my_scan_type != all_scan_mask)
{
if (scan_type != 0 && scan_type != my_scan_type)
if (scan_type != all_scan_mask && scan_type != my_scan_type)
scan_type_conflict = true;
if (!scan_type_conflict && scan_entry != NULL &&
my_entry->target->itarget != NULL &&
@ -1716,7 +1715,7 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
{
const uint64 my_scan_type = PGS_APPEND | PGS_MERGE_APPEND;
if (scan_type != 0 && scan_type != my_scan_type)
if (scan_type != all_scan_mask && scan_type != my_scan_type)
scan_type_conflict = true;
scan_entry = my_entry;
scan_type = my_scan_type;
@ -1795,7 +1794,7 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
if (matched_index == NULL)
{
/* Don't force the scan type if the index doesn't exist. */
scan_type = 0;
scan_type = all_scan_mask;
/* Mark advice as inapplicable. */
pgpa_trove_set_flags(scan_entries, scan_type_indexes,
@ -1839,14 +1838,8 @@ pgpa_planner_apply_scan_advice(RelOptInfo *rel,
* Only clear bits here, so that we still respect the enable_* GUCs. Do
* nothing in cases where the advice on a single topic conflicts.
*/
if (scan_type != 0 && !scan_type_conflict)
{
uint64 all_scan_mask;
all_scan_mask = PGS_SCAN_ANY | PGS_APPEND | PGS_MERGE_APPEND |
PGS_CONSIDER_INDEXONLY;
if (scan_type != all_scan_mask && !scan_type_conflict)
rel->pgs_mask &= ~(all_scan_mask & ~scan_type);
}
if (gather_mask != 0 && !gather_conflict)
{
uint64 all_gather_mask;
@ -2001,9 +1994,38 @@ pgpa_planner_get_proot(pgpa_planner_state *pps, PlannerInfo *root)
}
}
/* Create new object, add to list, and make it most recently used. */
/* Create new object. */
new_proot = palloc0_object(pgpa_planner_info);
/* Set plan name and alternative plan name. */
new_proot->plan_name = root->plan_name;
new_proot->alternative_plan_name = root->alternative_plan_name;
/*
* If the newly-created proot shares an alternative_plan_name with one or
* more others, all should have the is_alternative_plan flag set.
*/
foreach_ptr(pgpa_planner_info, other_proot, pps->proots)
{
if (strings_equal_or_both_null(new_proot->alternative_plan_name,
other_proot->alternative_plan_name))
{
new_proot->is_alternative_plan = true;
other_proot->is_alternative_plan = true;
}
}
/*
* Outermost query level always has rtoffset 0; other rtoffset values are
* computed later.
*/
if (root->plan_name == NULL)
{
new_proot->has_rtoffset = true;
new_proot->rtoffset = 0;
}
/* Add to list and make it most recently used. */
pps->proots = lappend(pps->proots, new_proot);
pps->last_proot = new_proot;
@ -2011,19 +2033,15 @@ pgpa_planner_get_proot(pgpa_planner_state *pps, PlannerInfo *root)
}
/*
* Save the range table identifier for one relation for future cross-checking.
* Compute the range table identifier for one relation and save it for future
* use.
*/
static void
pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
RelOptInfo *rel)
pgpa_compute_rt_identifier(pgpa_planner_info *proot, PlannerInfo *root,
RelOptInfo *rel)
{
#ifdef USE_ASSERT_CHECKING
pgpa_planner_info *proot;
pgpa_identifier *rid;
/* Get the pgpa_planner_info for this PlannerInfo. */
proot = pgpa_planner_get_proot(pps, root);
/* Allocate or extend the proot's rid_array as necessary. */
if (proot->rid_array_size < rel->relid)
{
@ -2043,7 +2061,60 @@ pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
rid = &proot->rid_array[rel->relid - 1];
if (rid->alias_name == NULL)
pgpa_compute_identifier_by_rti(root, rel->relid, rid);
#endif
}
/*
* Compute the range table offset for each pgpa_planner_info for which it
* is possible to meaningfully do so.
*/
static void
pgpa_compute_rt_offsets(pgpa_planner_state *pps, PlannedStmt *pstmt)
{
foreach_ptr(pgpa_planner_info, proot, pps->proots)
{
/* For the top query level, we've previously set rtoffset 0. */
if (proot->plan_name == NULL)
{
Assert(proot->has_rtoffset);
continue;
}
/*
* It's not guaranteed that every plan name we saw during planning has
* a SubPlanInfo, but any that do not certainly don't appear in the
* final range table.
*/
foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
{
if (strcmp(proot->plan_name, rtinfo->plan_name) == 0)
{
/*
* If rtinfo->dummy is set, then the subquery's range table
* will only have been partially copied to the final range
* table. Specifically, only RTE_RELATION entries and
* RTE_SUBQUERY entries that were once RTE_RELATION entries
* will be copied, as per add_rtes_to_flat_rtable. Therefore,
* there's no fixed rtoffset that we can apply to the RTIs
* used during planning to locate the corresponding relations.
*/
if (rtinfo->dummy)
{
/*
* It will not be possible to make any effective use of the
* sj_unique_rels list in this case, and it also won't be
* important to do so. So just throw the list away to avoid
* confusing pgpa_plan_walker.
*/
proot->sj_unique_rels = NIL;
break;
}
Assert(!proot->has_rtoffset);
proot->has_rtoffset = true;
proot->rtoffset = rtinfo->rtoffset;
break;
}
}
}
}
/*
@ -2051,7 +2122,7 @@ pgpa_ri_checker_save(pgpa_planner_state *pps, PlannerInfo *root,
* planning match the ones we generated from the final plan.
*/
static void
pgpa_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
pgpa_validate_rt_identifiers(pgpa_planner_state *pps, PlannedStmt *pstmt)
{
#ifdef USE_ASSERT_CHECKING
pgpa_identifier *rt_identifiers;
@ -2063,48 +2134,12 @@ pgpa_ri_checker_validate(pgpa_planner_state *pps, PlannedStmt *pstmt)
/* Iterate over identifiers created during planning, so we can compare. */
foreach_ptr(pgpa_planner_info, proot, pps->proots)
{
int rtoffset = 0;
/*
* If there's no plan name associated with this entry, then the
* rtoffset is 0. Otherwise, we can search the SubPlanRTInfo list to
* find the rtoffset.
*/
if (proot->plan_name != NULL)
{
foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
{
/*
* If rtinfo->dummy is set, then the subquery's range table
* will only have been partially copied to the final range
* table. Specifically, only RTE_RELATION entries and
* RTE_SUBQUERY entries that were once RTE_RELATION entries
* will be copied, as per add_rtes_to_flat_rtable. Therefore,
* there's no fixed rtoffset that we can apply to the RTIs
* used during planning to locate the corresponding relations
*/
if (strcmp(proot->plan_name, rtinfo->plan_name) == 0
&& !rtinfo->dummy)
{
rtoffset = rtinfo->rtoffset;
Assert(rtoffset > 0);
break;
}
}
/*
* It's not an error if we don't find the plan name: that just
* means that we planned a subplan by this name but it ended up
* being a dummy subplan and so wasn't included in the final plan
* tree.
*/
if (rtoffset == 0)
continue;
}
if (!proot->has_rtoffset)
continue;
for (int rti = 1; rti <= proot->rid_array_size; ++rti)
{
Index flat_rti = rtoffset + rti;
Index flat_rti = proot->rtoffset + rti;
pgpa_identifier *rid1 = &proot->rid_array[rti - 1];
pgpa_identifier *rid2;

View file

@ -24,11 +24,33 @@ typedef struct pgpa_planner_info
/* Plan name taken from the corresponding PlannerInfo; NULL at top level. */
char *plan_name;
#ifdef USE_ASSERT_CHECKING
/*
* If the corresponding PlannerInfo has an alternative_root, then this is
* the plan name from that PlannerInfo; otherwise, it is the same as
* plan_name.
*
* is_alternative_plan is set to true for every pgpa_planner_info that
* shares an alternative_plan_name with at least one other, and to false
* otherwise.
*/
char *alternative_plan_name;
bool is_alternative_plan;
/* Relation identifiers computed for baserels at this query level. */
pgpa_identifier *rid_array;
int rid_array_size;
#endif
/*
* If has_rtoffset is true, then rtoffset is the offset required to align
* RTIs for this query level with RTIs from the final, flattened rangetable.
* If has_rtoffset is false, then this subquery's range table wasn't copied,
* or was only partially copied, into the final range table. (Note that
* we can't determine the rtoffset values until the final range table
* actually exists; before that time, has_rtoffset will be false everywhere
* except at the top level.)
*/
bool has_rtoffset;
Index rtoffset;
/*
* List of Bitmapset objects. Each represents the relid set of a relation

View file

@ -162,6 +162,7 @@ pgpa_build_trove(List *advice_items)
break;
case PGPA_TAG_BITMAP_HEAP_SCAN:
case PGPA_TAG_DO_NOT_SCAN:
case PGPA_TAG_INDEX_ONLY_SCAN:
case PGPA_TAG_INDEX_SCAN:
case PGPA_TAG_SEQ_SCAN:

View file

@ -59,6 +59,10 @@ static bool pgpa_walker_contains_join(pgpa_plan_walker_context *walker,
Bitmapset *relids);
static bool pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
Bitmapset *relids);
static void pgpa_classify_alternative_subplans(pgpa_plan_walker_context *walker,
List *proots,
List **chosen_proots,
List **discarded_proots);
/*
* Top-level entrypoint for the plan tree walk.
@ -75,6 +79,8 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
ListCell *lc;
List *sj_unique_rtis = NULL;
List *sj_nonunique_qfs = NULL;
List *chosen_proots;
List *discarded_proots;
/* Initialization. */
memset(walker, 0, sizeof(pgpa_plan_walker_context));
@ -95,42 +101,23 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
/* Adjust RTIs from sj_unique_rels for the flattened range table. */
foreach_ptr(pgpa_planner_info, proot, proots)
{
int rtoffset = 0;
bool dummy = false;
/* If there are no sj_unique_rels for this proot, we can skip it. */
if (proot->sj_unique_rels == NIL)
continue;
/* If this is a subplan, find the range table offset. */
if (proot->plan_name != NULL)
{
foreach_node(SubPlanRTInfo, rtinfo, pstmt->subrtinfos)
{
if (strcmp(proot->plan_name, rtinfo->plan_name) == 0)
{
rtoffset = rtinfo->rtoffset;
dummy = rtinfo->dummy;
break;
}
}
if (!proot->has_rtoffset)
elog(ERROR, "no rtoffset for plan %s", proot->plan_name);
if (rtoffset == 0)
elog(ERROR, "no rtoffset for plan %s", proot->plan_name);
}
/* If this entry pertains to a dummy subquery, ignore it. */
if (dummy)
continue;
/* Offset each relid set by the rtoffset we just computed. */
/* Offset each relid set by the proot's rtoffset. */
foreach_node(Bitmapset, relids, proot->sj_unique_rels)
{
int rtindex = -1;
Bitmapset *flat_relids = NULL;
while ((rtindex = bms_next_member(relids, rtindex)) >= 0)
flat_relids = bms_add_member(flat_relids, rtindex + rtoffset);
flat_relids = bms_add_member(flat_relids,
rtindex + proot->rtoffset);
sj_unique_rtis = lappend(sj_unique_rtis, flat_relids);
}
@ -193,6 +180,42 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
walker->query_features[t] = query_features;
}
/* Classify alternative subplans. */
pgpa_classify_alternative_subplans(walker, proots,
&chosen_proots, &discarded_proots);
/*
* Figure out which of the discarded alternatives have a non-discarded
* alternative. Those are the ones for which we want to emit DO_NOT_SCAN
* advice. (If every alternative was discarded, then there's no point.)
*/
foreach_ptr(pgpa_planner_info, discarded_proot, discarded_proots)
{
bool some_alternative_chosen = false;
foreach_ptr(pgpa_planner_info, chosen_proot, chosen_proots)
{
if (strings_equal_or_both_null(discarded_proot->alternative_plan_name,
chosen_proot->alternative_plan_name))
{
some_alternative_chosen = true;
break;
}
}
if (some_alternative_chosen)
{
for (int rti = 1; rti <= discarded_proot->rid_array_size; rti++)
{
pgpa_identifier *rid = &discarded_proot->rid_array[rti - 1];
if (rid->alias_name != NULL)
walker->do_not_scan_identifiers =
lappend(walker->do_not_scan_identifiers, rid);
}
}
}
}
/*
@ -697,6 +720,30 @@ pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
return false;
}
/*
* DO_NOT_SCAN advice targets rels that may not be in the flat range table
* (e.g. MinMaxAgg losers), so we can't use pgpa_compute_rti_from_identifier.
* Instead, check directly against the do_not_scan_identifiers list.
*/
if (tag == PGPA_TAG_DO_NOT_SCAN)
{
if (target->ttype != PGPA_TARGET_IDENTIFIER)
return false;
foreach_ptr(pgpa_identifier, rid, walker->do_not_scan_identifiers)
{
if (strcmp(rid->alias_name, target->rid.alias_name) == 0 &&
rid->occurrence == target->rid.occurrence &&
strings_equal_or_both_null(rid->partnsp,
target->rid.partnsp) &&
strings_equal_or_both_null(rid->partrel,
target->rid.partrel) &&
strings_equal_or_both_null(rid->plan_name,
target->rid.plan_name))
return true;
}
return false;
}
if (target->ttype == PGPA_TARGET_IDENTIFIER)
{
Index rti;
@ -730,6 +777,10 @@ pgpa_walker_would_advise(pgpa_plan_walker_context *walker,
/* should have been handled above */
pg_unreachable();
break;
case PGPA_TAG_DO_NOT_SCAN:
/* should have been handled above */
pg_unreachable();
break;
case PGPA_TAG_BITMAP_HEAP_SCAN:
return pgpa_walker_find_scan(walker,
PGPA_SCAN_BITMAP_HEAP,
@ -1035,3 +1086,60 @@ pgpa_walker_contains_no_gather(pgpa_plan_walker_context *walker,
{
return bms_is_subset(relids, walker->no_gather_scans);
}
/*
* Classify alternative subplans as chosen or discarded.
*/
static void
pgpa_classify_alternative_subplans(pgpa_plan_walker_context *walker,
List *proots,
List **chosen_proots,
List **discarded_proots)
{
Bitmapset *all_scan_rtis = NULL;
/* Initialize both output lists to empty. */
*chosen_proots = NIL;
*discarded_proots = NIL;
/* Collect all scan RTIs. */
for (int s = 0; s < NUM_PGPA_SCAN_STRATEGY; s++)
foreach_ptr(pgpa_scan, scan, walker->scans[s])
all_scan_rtis = bms_add_members(all_scan_rtis, scan->relids);
/* Now classify each subplan. */
foreach_ptr(pgpa_planner_info, proot, proots)
{
bool chosen = false;
/*
* We're only interested in classifying subplans for which there are
* alternatives.
*/
if (!proot->is_alternative_plan)
continue;
/*
* A subplan has been chosen if any of its scan RTIs appear in the
* final plan. This cannot be the case if it has no RT offset.
*/
if (proot->has_rtoffset)
{
for (int rti = 1; rti <= proot->rid_array_size; rti++)
{
if (proot->rid_array[rti - 1].alias_name != NULL &&
bms_is_member(proot->rtoffset + rti, all_scan_rtis))
{
chosen = true;
break;
}
}
}
/* Add it to the correct list. */
if (chosen)
*chosen_proots = lappend(*chosen_proots, proot);
else
*discarded_proots = lappend(*discarded_proots, proot);
}
}

View file

@ -100,6 +100,7 @@ typedef struct pgpa_plan_walker_context
List *join_strategies[NUM_PGPA_JOIN_STRATEGY];
List *query_features[NUM_PGPA_QF_TYPES];
List *future_query_features;
List *do_not_scan_identifiers;
} pgpa_plan_walker_context;
extern void pgpa_plan_walker(pgpa_plan_walker_context *walker,

View file

@ -0,0 +1,58 @@
LOAD 'pg_plan_advice';
SET max_parallel_workers_per_gather = 0;
CREATE TABLE alt_t1 (a int) WITH (autovacuum_enabled = false);
CREATE TABLE alt_t2 (a int) WITH (autovacuum_enabled = false);
CREATE INDEX ON alt_t2(a);
INSERT INTO alt_t1 SELECT generate_series(1, 1000);
INSERT INTO alt_t2 SELECT generate_series(1, 100000);
VACUUM ANALYZE alt_t1;
VACUUM ANALYZE alt_t2;
-- This query uses an OR to prevent the EXISTS from being converted to a
-- semi-join, forcing the planner through the AlternativeSubPlan path.
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM alt_t1
WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
-- We should be able to force either AlternativeSubPlan by advising against
-- scanning the other relation.
BEGIN;
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_t2@exists_1)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM alt_t1
WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_t2@exists_2)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM alt_t1
WHERE EXISTS (SELECT 1 FROM alt_t2 WHERE alt_t2.a = alt_t1.a) OR alt_t1.a < 0;
COMMIT;
-- Now let's test a case involving MinMaxAggPath, which we treat similarly
-- to the AlternativeSubPlan case.
CREATE TABLE alt_minmax (a int) WITH (autovacuum_enabled = false);
CREATE INDEX ON alt_minmax(a);
INSERT INTO alt_minmax SELECT generate_series(1, 10000);
VACUUM ANALYZE alt_minmax;
-- Using an Index Scan inside of an InitPlan should win over a full table
-- scan.
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT min(a), max(a) FROM alt_minmax;
-- Advising against the scan of alt_minmax at the root query level should
-- change nothing, but if we say we don't want either of or both of the
-- minmax-variant scans, the plan should switch to a full table scan.
BEGIN;
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT min(a), max(a) FROM alt_minmax;
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax@minmax_1)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT min(a), max(a) FROM alt_minmax;
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(alt_minmax@minmax_1) DO_NOT_SCAN(alt_minmax@minmax_2)';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT min(a), max(a) FROM alt_minmax;
COMMIT;
DROP TABLE alt_t1, alt_t2, alt_minmax;

View file

@ -79,7 +79,8 @@ COMMIT;
-- We can force a primary key lookup to use a sequential scan, but we
-- can't force it to use an index-only scan (due to the column list)
-- or a TID scan (due to the absence of a TID qual).
-- or a TID scan (due to the absence of a TID qual). If we apply DO_NOT_SCAN
-- here, we should get a valid plan anyway, but with the scan disabled.
BEGIN;
SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
@ -87,6 +88,8 @@ SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
SET LOCAL pg_plan_advice.advice = 'DO_NOT_SCAN(scan_table)';
EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
COMMIT;
-- We can forcibly downgrade an index-only scan to an index scan, but we can't

View file

@ -267,7 +267,8 @@ TID_SCAN(<replaceable>target</replaceable> [ ... ])
INDEX_SCAN(<replaceable>target</replaceable> <replaceable>index_name</replaceable> [ ... ])
INDEX_ONLY_SCAN(<replaceable>target</replaceable> <replaceable>index_name</replaceable> [ ... ])
FOREIGN_SCAN((<replaceable>target</replaceable> [ ... ]) [ ... ])
BITMAP_HEAP_SCAN(<replaceable>target</replaceable> [ ... ])</synopsis>
BITMAP_HEAP_SCAN(<replaceable>target</replaceable> [ ... ])
DO_NOT_SCAN(<replaceable>target</replaceable> [ ... ])</synopsis>
<para>
<literal>SEQ_SCAN</literal> specifies that each target should be
@ -297,6 +298,17 @@ BITMAP_HEAP_SCAN(<replaceable>target</replaceable> [ ... ])</synopsis>
that purpose.
</para>
<para>
<literal>DO_NOT_SCAN</literal> specifies that a particular target
should not appear in the final plan at all. In most cases, this is
impossible, and will simply cause the scan of the target relation to
be marked disabled. However, in certain cases, the planner considers
optimizations where a portion of the plan tree is copied and mutated,
and then considered as an alternative to the original. In those cases,
<literal>DO_NOT_SCAN</literal> can be used to exclude the non-preferred
alternative.
</para>
<para>
The planner supports many types of scans other than those listed here;
however, in most of those cases, there is no meaningful decision to be