From 97c4364233f926ce611578de232d536ddaa13032 Mon Sep 17 00:00:00 2001 From: zengxx Date: Wed, 21 Oct 2026 19:01:57 +0800 Subject: [PATCH 2/3] Add parallel partial deduplication for UNION Introduce UPPERREL_PARTIAL_SETOP and build a worker-local HashAgg below Gather for UNION when the input has partial paths. The cost model decides whether the partial path wins. Expose the stage through create_upper_paths_hook with a NULL input relation and cover that contract with a new test module. The partial stage is currently UNION-only; INTERSECT/EXCEPT keep SetOp semantics, and an assertion documents that assumption. Propagate the reliability of the child group estimate from the expressions that produced it: grouped children use the processed group clauses, DISTINCT children use the processed DISTINCT clause, and nested set-operation children are conservatively unreliable. When the estimate is weak, check the worst-case worker-local hash table against hash_mem before costing the partial path, following the existing SetOp memory-fit policy. Add regression coverage for the new plan shape, the memory-aware fallback, grouped/DISTINCT/nested children, ORDER BY + LIMIT, GUC interactions, and execution of the parallel plan. --- src/backend/optimizer/README | 8 + src/backend/optimizer/prep/prepunion.c | 258 ++++++++++++-- src/include/nodes/pathnodes.h | 1 + src/include/optimizer/planner.h | 15 +- src/test/modules/Makefile | 1 + src/test/modules/meson.build | 1 + .../modules/test_upper_paths_hook/.gitignore | 4 + .../modules/test_upper_paths_hook/Makefile | 20 ++ .../expected/test_upper_paths_hook.out | 126 +++++++ .../modules/test_upper_paths_hook/meson.build | 28 ++ .../sql/test_upper_paths_hook.sql | 69 ++++ .../test_upper_paths_hook.c | 79 +++++ src/test/regress/expected/union.out | 323 ++++++++++++++++++ src/test/regress/sql/union.sql | 128 +++++++ 14 files changed, 1027 insertions(+), 34 deletions(-) create mode 100644 src/test/modules/test_upper_paths_hook/.gitignore create mode 100644 src/test/modules/test_upper_paths_hook/Makefile create mode 100644 src/test/modules/test_upper_paths_hook/expected/test_upper_paths_hook.out create mode 100644 src/test/modules/test_upper_paths_hook/meson.build create mode 100644 src/test/modules/test_upper_paths_hook/sql/test_upper_paths_hook.sql create mode 100644 src/test/modules/test_upper_paths_hook/test_upper_paths_hook.c diff --git a/src/backend/optimizer/README b/src/backend/optimizer/README index 73d0b75..a8443b5 100644 --- a/src/backend/optimizer/README +++ b/src/backend/optimizer/README @@ -1353,6 +1353,7 @@ RelOptInfos are mostly dummy, but their pathlist lists hold all the Paths considered useful for each step. Currently, we may create these types of additional RelOptInfos during upper-level planning: +UPPERREL_PARTIAL_SETOP result of partial setop deduplication, if any UPPERREL_SETOP result of UNION/INTERSECT/EXCEPT, if any UPPERREL_PARTIAL_GROUP_AGG result of partial grouping/aggregation, if any UPPERREL_GROUP_AGG result of grouping/aggregation, if any @@ -1367,6 +1368,13 @@ LockRows (SELECT FOR UPDATE), LIMIT/OFFSET, and ModifyTable. There is no flexibility about the order in which these steps are done, and thus no need to subdivide this stage more finely. +UPPERREL_PARTIAL_SETOP is used for worker-local de-duplication of UNION +(and, in the future, other set operations). It is created only when the +planner is considering a partial de-duplication path, and its output relation +initially receives partial paths; the final de-duplication path is added to +the corresponding UPPERREL_SETOP relation. Extensions may see this stage +more than once in a query with multiple non-flattened set operations. + These "upper relations" are identified by the UPPERREL enum values shown above, plus a relids set, which allows there to be more than one upperrel of the same kind. We use NULL for the relids if there's no need for more diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c index 02ac58b..a1376e1 100644 --- a/src/backend/optimizer/prep/prepunion.c +++ b/src/backend/optimizer/prep/prepunion.c @@ -27,10 +27,12 @@ #include "access/htup_details.h" #include "catalog/pg_type.h" +#include "executor/nodeSetOp.h" #include "miscadmin.h" #include "nodes/makefuncs.h" #include "nodes/nodeFuncs.h" #include "optimizer/cost.h" +#include "optimizer/optimizer.h" #include "optimizer/pathnode.h" #include "optimizer/paths.h" #include "optimizer/planner.h" @@ -54,7 +56,8 @@ static RelOptInfo *generate_recursion_path(SetOperationStmt *setOp, static void build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel, bool trivial_tlist, List *child_tlist, List *interesting_pathkeys, - double *pNumGroups); + double *pNumGroups, + bool *pUnreliableStats); static RelOptInfo *generate_union_paths(SetOperationStmt *op, PlannerInfo *root, List *refnames_tlist, List **pTargetList); @@ -398,7 +401,7 @@ generate_recursion_path(SetOperationStmt *setOp, PlannerInfo *root, &lpath_trivial_tlist); if (lrel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, lrel, lpath_trivial_tlist, lpath_tlist, - NIL, NULL); + NIL, NULL, NULL); lpath = lrel->cheapest_total_path; /* The right path will want to look at the left one ... */ root->non_recursive_path = lpath; @@ -410,7 +413,7 @@ generate_recursion_path(SetOperationStmt *setOp, PlannerInfo *root, &rpath_trivial_tlist); if (rrel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, rrel, rpath_trivial_tlist, rpath_tlist, - NIL, NULL); + NIL, NULL, NULL); rpath = rrel->cheapest_total_path; root->non_recursive_path = NULL; @@ -484,11 +487,16 @@ generate_recursion_path(SetOperationStmt *setOp, PlannerInfo *root, * pathkeys, sorting any unsorted paths as required. * *pNumGroups: if not NULL, we estimate the number of distinct groups * in the result, and store it there. + * *pUnreliableStats: if not NULL, set to true when the group estimate is + * based on default statistics or was capped by the multi-column correlation + * clamp. Grouping-set children are conservatively treated as unreliable. + * Such estimates must not be used to cost parallel deduplication. */ static void build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel, bool trivial_tlist, List *child_tlist, - List *interesting_pathkeys, double *pNumGroups) + List *interesting_pathkeys, double *pNumGroups, + bool *pUnreliableStats) { RelOptInfo *final_rel; List *setop_pathkeys = rel->subroot->setop_pathkeys; @@ -497,6 +505,9 @@ build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel, /* it can't be a set op child rel if it's not a subquery */ Assert(rel->rtekind == RTE_SUBQUERY); + if (pUnreliableStats) + *pUnreliableStats = false; + /* when sorting is needed, add child rel equivalences */ if (interesting_pathkeys != NIL) add_setop_child_rel_equivalences(root, @@ -670,17 +681,106 @@ build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel, { PlannerInfo *subroot = rel->subroot; Query *subquery = subroot->parse; + EstimationInfo estinfo = {0}; + List *groupExprs = NIL; + bool reliable = true; + bool estimated = false; + + /* + * A child that is itself a set operation exposes varno-0 output + * expressions, which estimate_num_groups() cannot use reliably. + * Treat its estimate as unreliable. + * + * Note: grouped, DISTINCT and nested-setop children generally do + * not expose partial paths in the current planner, so the UNION + * worker-side dedup path is normally not built for them (see + * partial_paths_valid in generate_union_paths). We still compute + * this state correctly for partitionwise aggregation and for + * future path shapes; the regression tests cover the current plan + * shape for these children. + */ + if (subquery->setOperations != NULL) + reliable = false; if (subquery->groupClause || subquery->groupingSets || - subquery->distinctClause || subroot->hasHavingQual || - subquery->hasAggs) + subroot->hasHavingQual || subquery->hasAggs) + { *pNumGroups = rel->cheapest_total_path->rows; + + /* + * The row estimate comes from the subquery's own upper-level + * planning, and the statistics reliability must be judged using + * the same expressions that produced that estimate. Mirror + * get_number_of_groups(), which estimates a plain GROUP BY with + * the processed grouping expressions; the subquery's output + * target list can be a completely different set of expressions + * (for example, it may contain only "count(*)"). + * + * Grouping sets are treated conservatively as unreliable: the + * per-set estimates are not retained on the subroot, so we + * cannot reconstruct them here. + * + * In most cases a grouped child exposes no partial paths, so the + * UNION parallel-dedup path is not built at all (see + * partial_paths_valid in generate_union_paths). We still compute + * this correctly so the decision remains sound if partial paths + * become available, for example with partitionwise aggregation. + */ + if (pUnreliableStats) + { + if (subquery->groupingSets) + reliable = false; + else if (subquery->groupClause) + groupExprs = get_sortgrouplist_exprs(subroot->processed_groupClause, + subquery->targetList); + /* else: plain aggregation, exactly one row */ + } + } + else if (subquery->distinctClause) + { + *pNumGroups = rel->cheapest_total_path->rows; + + /* + * For DISTINCT without grouping or aggregation, the row estimate + * comes from create_final_distinct_paths(), which estimates the + * distinct expressions. Mirror that here. + */ + if (pUnreliableStats) + groupExprs = get_sortgrouplist_exprs(subroot->processed_distinctClause, + subquery->targetList); + } else + { + groupExprs = get_tlist_exprs(subquery->targetList, false); *pNumGroups = estimate_num_groups(subroot, - get_tlist_exprs(subroot->parse->targetList, false), + groupExprs, rel->cheapest_total_path->rows, NULL, - NULL); + &estinfo); + estimated = true; + } + + /* + * For grouped and DISTINCT children, run the estimator only to learn + * whether the inputs are trustworthy; the return value is deliberately + * discarded. The input_rows value does not affect the flags we care + * about: SELFLAG_USED_DEFAULT depends only on the available + * statistics, and SELFLAG_USED_MULTIVAR_CLAMP depends only on the + * relation size and the number of grouping variables. So passing the + * post-grouping row estimate here is fine. + */ + if (pUnreliableStats) + { + if (!estimated && groupExprs != NIL) + (void) estimate_num_groups(subroot, groupExprs, + rel->cheapest_total_path->rows, + NULL, &estinfo); + + if (estimate_num_groups_unreliable(&estinfo)) + reliable = false; + + *pUnreliableStats = !reliable; + } } } @@ -709,9 +809,11 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, List *groupList = NIL; Path *apath; Path *gpath = NULL; + Path *papath = NULL; bool try_sorted = false; List *union_pathkeys = NIL; double dNumChildGroups = 0; + bool dNumChildGroupsUnreliable = false; /* * If any of my children are identical UNION nodes (same op, all-flag, and @@ -763,14 +865,23 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, bool trivial_tlist = lfirst_int(lc2); List *child_tlist = lfirst_node(List, lc3); double childGroups = 0; + bool childUnreliableStats = true; /* only build paths for the union children */ if (rel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, rel, trivial_tlist, child_tlist, union_pathkeys, - op->all ? NULL : &childGroups); + op->all ? NULL : &childGroups, + op->all ? NULL : &childUnreliableStats); else + { + /* + * A non-subquery child is usually a nested set operation. We do + * not retain a reliability flag for its row estimate, so treat it + * conservatively. + */ childGroups = rel->rows; + } /* * For UNION (not UNION ALL), accumulate the per-child distinct-group @@ -781,7 +892,11 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, * them. */ if (!op->all && !is_dummy_rel(rel)) + { dNumChildGroups += childGroups; + if (childUnreliableStats) + dNumChildGroupsUnreliable = true; + } } /* Build path lists and relid set. */ @@ -886,9 +1001,15 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, */ if (partial_paths_valid) { - Path *papath; + double gather_rows; int parallel_workers = 0; + /* + * UPPERREL_PARTIAL_SETOP is currently created only for UNION. + * INTERSECT/EXCEPT keep SetOp semantics and do not use this path. + */ + Assert(op->op == SETOP_UNION); + /* Find the highest number of workers requested for any subpath. */ foreach(lc, partial.partial_subpaths) { @@ -922,9 +1043,16 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, /* See comment above about pathkeys vs. "varno 0" Vars */ papath->pathkeys = NIL; + /* + * Pass the Gather row estimate explicitly. The total Append row + * count can differ from this after a constant child's partial path + * is limited to one row per worker: each worker still contributes a + * row, while the total path contributes only one. + */ + gather_rows = compute_gather_rows(papath); gpath = (Path *) create_gather_path(root, result_rel, papath, - result_rel->reltarget, NULL, NULL); + result_rel->reltarget, NULL, &gather_rows); } if (!op->all) @@ -932,6 +1060,14 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, bool can_sort = grouping_is_sortable(groupList); bool can_hash = grouping_is_hashable(groupList); + /* + * The de-duplication path shapes here use the helpers shared with + * DISTINCT (see planner.h). Note that UNION cannot call + * create_final_distinct_paths() directly: its input paths are + * Append/Gather paths whose output columns are varno-0 Vars without + * statistics, its group count is derived from the children, and + * MergeAppend is specific to set operations. + */ /* * result_rel->rows was initialized to the total input size above, * which is the correct estimate for UNION ALL. A UNION removes @@ -948,34 +1084,90 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, * Try a hash aggregate plan on 'apath'. This is the cheapest * available path containing each append child. */ - path = (Path *) create_agg_path(root, - result_rel, - apath, - result_rel->reltarget, - AGG_HASHED, - AGGSPLIT_SIMPLE, - groupList, - NIL, - NULL, - dNumChildGroups); + path = create_dedup_hashagg_path(root, result_rel, apath, + result_rel->reltarget, + groupList, dNumChildGroups); add_path(result_rel, path); /* Try hash aggregate on the Gather path, if valid */ if (gpath != NULL) { /* Hashed aggregate plan --- no sort needed */ - path = (Path *) create_agg_path(root, - result_rel, - gpath, - result_rel->reltarget, - AGG_HASHED, - AGGSPLIT_SIMPLE, - groupList, - NIL, - NULL, - dNumChildGroups); + path = create_dedup_hashagg_path(root, result_rel, gpath, + result_rel->reltarget, + groupList, + dNumChildGroups); add_path(result_rel, path); } + + /* + * Try deduplicating inside each worker before gathering: the same + * shape DISTINCT uses for parallel deduplication. With + * enable_hashagg off, cost_agg marks this path as disabled, so it + * is only chosen when no enabled alternative exists. + * + * When the group estimate is unreliable (default statistics or + * the multi-column correlation clamp), decide whether the worst + * case is dangerous. The worst case is one group per input row. + * If even that hash table fits in hash_mem, keep the normal + * estimate and let add_path() decide. Otherwise cost the path + * with the worst-case group count, which makes it lose unless it + * is the only alternative. This follows the same memory-fit + * policy that create_setop_path() uses for HashSetOp. + * + * This is conservative: the clamp can also fire on a correlated + * low-cardinality key whose raw estimate was a harmless + * overestimate. Without extended statistics we cannot tell those + * cases apart; users can enable the optimization by creating + * extended statistics on the dedup key. + */ + if (papath != NULL && + papath->parallel_workers > 0 && + dNumChildGroups > 0) + { + RelOptInfo *partial_rel; + double dNumPartialGroups; + + /* + * A worker cannot produce more groups than it has input rows, + * and cannot exceed the union-wide estimate. + */ + if (dNumChildGroupsUnreliable) + { + Size hashsize; + + /* + * The worker hash table holds only this worker's rows, so + * use the per-worker input estimate. The SetOp per-group + * allowance is conservative for a grouping-only HashAgg, + * but keeps the gate on the safe side for near-unique + * keys. + */ + hashsize = EstimateSetOpHashTableSpace(papath->rows, + papath->pathtarget->width); + if (hashsize < get_hash_memory_limit()) + dNumPartialGroups = Min(dNumChildGroups, papath->rows); + else + dNumPartialGroups = papath->rows; + } + else + dNumPartialGroups = Min(dNumChildGroups, papath->rows); + + partial_rel = create_partial_dedup_rel(root, + UPPERREL_PARTIAL_SETOP, + result_rel, + result_rel->relids, + result_rel->reltarget); + add_partial_dedup_hash_path(root, partial_rel, papath, + groupList, dNumPartialGroups, + can_hash); + + finish_partial_dedup_rel(root, result_rel, NULL, + partial_rel, + UPPERREL_PARTIAL_SETOP, false); + add_final_dedup_hash_paths(root, partial_rel, result_rel, + groupList, dNumChildGroups); + } } if (can_sort) @@ -1146,12 +1338,12 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root, */ if (lrel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, lrel, lpath_trivial_tlist, lpath_tlist, - nonunion_pathkeys, &dLeftGroups); + nonunion_pathkeys, &dLeftGroups, NULL); else dLeftGroups = lrel->rows; if (rrel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, rrel, rpath_trivial_tlist, rpath_tlist, - nonunion_pathkeys, &dRightGroups); + nonunion_pathkeys, &dRightGroups, NULL); else dRightGroups = rrel->rows; diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h index 8615db9..8ea5f3e 100644 --- a/src/include/nodes/pathnodes.h +++ b/src/include/nodes/pathnodes.h @@ -141,6 +141,7 @@ typedef struct AggClauseCosts */ typedef enum UpperRelationKind { + UPPERREL_PARTIAL_SETOP, /* result of partial setop dedup, if any */ UPPERREL_SETOP, /* result of UNION/INTERSECT/EXCEPT, if any */ UPPERREL_PARTIAL_GROUP_AGG, /* result of partial grouping/aggregation, if * any */ diff --git a/src/include/optimizer/planner.h b/src/include/optimizer/planner.h index bda7d14..fe2e10d 100644 --- a/src/include/optimizer/planner.h +++ b/src/include/optimizer/planner.h @@ -46,7 +46,20 @@ typedef void (*planner_shutdown_hook_type) (PlannerGlobal *glob, Query *parse, PlannedStmt *pstmt); extern PGDLLIMPORT planner_shutdown_hook_type planner_shutdown_hook; -/* Hook for plugins to get control when grouping_planner() plans upper rels */ +/* + * Hook for plugins to get control when grouping_planner() plans upper rels. + * New stages may be added, so plugins must ignore stages they do not handle. + * input_rel may be NULL for set-operation stages, which have no finalized + * input relation; output_rel is always non-NULL and receives paths. extra + * is stage-specific and is currently NULL for UPPERREL_PARTIAL_SETOP. + * + * UPPERREL_PARTIAL_SETOP is invoked only when a partial set-operation + * de-duplication path is being considered, and can occur more than once in + * a query with multiple non-flattened set operations. Its output_rel + * initially contains partial paths; a plugin can add partial paths (and may + * add complete paths, which are also considered). The corresponding + * upper_targets[] entry is not initialized. + */ typedef void (*create_upper_paths_hook_type) (PlannerInfo *root, UpperRelationKind stage, RelOptInfo *input_rel, diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 6c1504a..2c60215 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -54,6 +54,7 @@ SUBDIRS = \ test_shm_mq \ test_slru \ test_tidstore \ + test_upper_paths_hook \ test_wait_lsn \ unsafe_tests \ worker_spi \ diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index ef3cdb8..c29347d 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -56,6 +56,7 @@ subdir('test_shmem') subdir('test_shm_mq') subdir('test_slru') subdir('test_tidstore') +subdir('test_upper_paths_hook') subdir('test_wait_lsn') subdir('typcache') subdir('unsafe_tests') diff --git a/src/test/modules/test_upper_paths_hook/.gitignore b/src/test/modules/test_upper_paths_hook/.gitignore new file mode 100644 index 0000000..5dcb3ff --- /dev/null +++ b/src/test/modules/test_upper_paths_hook/.gitignore @@ -0,0 +1,4 @@ +# Generated subdirectories +/log/ +/results/ +/tmp_check/ diff --git a/src/test/modules/test_upper_paths_hook/Makefile b/src/test/modules/test_upper_paths_hook/Makefile new file mode 100644 index 0000000..834b148 --- /dev/null +++ b/src/test/modules/test_upper_paths_hook/Makefile @@ -0,0 +1,20 @@ +# src/test/modules/test_upper_paths_hook/Makefile + +MODULE_big = test_upper_paths_hook +OBJS = \ + $(WIN32RES) \ + test_upper_paths_hook.o +PGFILEDESC = "test_upper_paths_hook - test create_upper_paths_hook" + +REGRESS = test_upper_paths_hook + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_upper_paths_hook +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_upper_paths_hook/expected/test_upper_paths_hook.out b/src/test/modules/test_upper_paths_hook/expected/test_upper_paths_hook.out new file mode 100644 index 0000000..9e94548 --- /dev/null +++ b/src/test/modules/test_upper_paths_hook/expected/test_upper_paths_hook.out @@ -0,0 +1,126 @@ +LOAD 'test_upper_paths_hook'; +CREATE FUNCTION test_upper_paths_hook_seen() RETURNS bool +AS 'test_upper_paths_hook', 'test_upper_paths_hook_seen' LANGUAGE C; +CREATE FUNCTION test_upper_paths_hook_input_null() RETURNS bool +AS 'test_upper_paths_hook', 'test_upper_paths_hook_input_null' LANGUAGE C; +CREATE FUNCTION test_upper_paths_hook_output_seen() RETURNS bool +AS 'test_upper_paths_hook', 'test_upper_paths_hook_output_seen' LANGUAGE C; +CREATE FUNCTION test_upper_paths_hook_reset() RETURNS void +AS 'test_upper_paths_hook', 'test_upper_paths_hook_reset' LANGUAGE C; +SELECT test_upper_paths_hook_reset(); + test_upper_paths_hook_reset +----------------------------- + +(1 row) + +-- Force partial paths and make parallel plans attractive. +SET parallel_setup_cost = 0; +SET parallel_tuple_cost = 0; +SET min_parallel_table_scan_size = 0; +SET max_parallel_workers_per_gather = 2; +CREATE TABLE test_upper_paths_hook_t (a int, b int) + WITH (autovacuum_enabled = false); +INSERT INTO test_upper_paths_hook_t +SELECT g % 100, g % 100 FROM generate_series(1, 20000) g; +-- Extended statistics give the multi-column key a usable estimate. +CREATE STATISTICS test_upper_paths_hook_nd (ndistinct) +ON a, b FROM test_upper_paths_hook_t; +ANALYZE test_upper_paths_hook_t; +-- Planning this UNION creates the partial setop upper rel. +EXPLAIN (COSTS OFF) +SELECT a, b FROM test_upper_paths_hook_t +UNION +SELECT a, b FROM test_upper_paths_hook_t; + QUERY PLAN +------------------------------------------------------------------------------------------------ + HashAggregate + Group Key: test_upper_paths_hook_t.a, test_upper_paths_hook_t.b + -> Gather + Workers Planned: 2 + -> HashAggregate + Group Key: test_upper_paths_hook_t.a, test_upper_paths_hook_t.b + -> Parallel Append + -> Parallel Seq Scan on test_upper_paths_hook_t + -> Parallel Seq Scan on test_upper_paths_hook_t test_upper_paths_hook_t_1 +(9 rows) + +SELECT test_upper_paths_hook_seen(); + test_upper_paths_hook_seen +---------------------------- + t +(1 row) + +SELECT test_upper_paths_hook_input_null(); + test_upper_paths_hook_input_null +---------------------------------- + t +(1 row) + +SELECT test_upper_paths_hook_output_seen(); + test_upper_paths_hook_output_seen +----------------------------------- + t +(1 row) + +-- The partial path is still built when statistics are unreliable, so the +-- hook receives the stage. Whether it is chosen depends on the memory-aware +-- cost; for this small table the worst-case table fits in hash_mem, so it is +-- chosen. +SELECT test_upper_paths_hook_reset(); + test_upper_paths_hook_reset +----------------------------- + +(1 row) + +CREATE TABLE test_upper_paths_hook_nostat (a int, b int) + WITH (autovacuum_enabled = false); +INSERT INTO test_upper_paths_hook_nostat +SELECT g % 100, g % 100 FROM generate_series(1, 20000) g; +EXPLAIN (COSTS OFF) +SELECT a, b FROM test_upper_paths_hook_nostat +UNION +SELECT a, b FROM test_upper_paths_hook_nostat; + QUERY PLAN +---------------------------------------------------------------------------------------------------------- + HashAggregate + Group Key: test_upper_paths_hook_nostat.a, test_upper_paths_hook_nostat.b + -> Gather + Workers Planned: 2 + -> HashAggregate + Group Key: test_upper_paths_hook_nostat.a, test_upper_paths_hook_nostat.b + -> Parallel Append + -> Parallel Seq Scan on test_upper_paths_hook_nostat + -> Parallel Seq Scan on test_upper_paths_hook_nostat test_upper_paths_hook_nostat_1 +(9 rows) + +SELECT test_upper_paths_hook_seen(); + test_upper_paths_hook_seen +---------------------------- + t +(1 row) + +DROP TABLE test_upper_paths_hook_nostat; +-- A query without a set operation must not trigger the hook. +SELECT test_upper_paths_hook_reset(); + test_upper_paths_hook_reset +----------------------------- + +(1 row) + +SELECT count(*) FROM test_upper_paths_hook_t; + count +------- + 20000 +(1 row) + +SELECT test_upper_paths_hook_seen(); + test_upper_paths_hook_seen +---------------------------- + f +(1 row) + +DROP TABLE test_upper_paths_hook_t; +RESET parallel_setup_cost; +RESET parallel_tuple_cost; +RESET min_parallel_table_scan_size; +RESET max_parallel_workers_per_gather; diff --git a/src/test/modules/test_upper_paths_hook/meson.build b/src/test/modules/test_upper_paths_hook/meson.build new file mode 100644 index 0000000..6b02483 --- /dev/null +++ b/src/test/modules/test_upper_paths_hook/meson.build @@ -0,0 +1,28 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +test_upper_paths_hook_sources = files( + 'test_upper_paths_hook.c', +) + +if host_system == 'windows' + test_upper_paths_hook_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_upper_paths_hook', + '--FILEDESC', 'test_upper_paths_hook - test create_upper_paths_hook',]) +endif + +test_upper_paths_hook = shared_module('test_upper_paths_hook', + test_upper_paths_hook_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_upper_paths_hook + +tests += { + 'name': 'test_upper_paths_hook', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'test_upper_paths_hook', + ], + }, +} diff --git a/src/test/modules/test_upper_paths_hook/sql/test_upper_paths_hook.sql b/src/test/modules/test_upper_paths_hook/sql/test_upper_paths_hook.sql new file mode 100644 index 0000000..756b297 --- /dev/null +++ b/src/test/modules/test_upper_paths_hook/sql/test_upper_paths_hook.sql @@ -0,0 +1,69 @@ +LOAD 'test_upper_paths_hook'; + +CREATE FUNCTION test_upper_paths_hook_seen() RETURNS bool +AS 'test_upper_paths_hook', 'test_upper_paths_hook_seen' LANGUAGE C; + +CREATE FUNCTION test_upper_paths_hook_input_null() RETURNS bool +AS 'test_upper_paths_hook', 'test_upper_paths_hook_input_null' LANGUAGE C; + +CREATE FUNCTION test_upper_paths_hook_output_seen() RETURNS bool +AS 'test_upper_paths_hook', 'test_upper_paths_hook_output_seen' LANGUAGE C; + +CREATE FUNCTION test_upper_paths_hook_reset() RETURNS void +AS 'test_upper_paths_hook', 'test_upper_paths_hook_reset' LANGUAGE C; + +SELECT test_upper_paths_hook_reset(); + +-- Force partial paths and make parallel plans attractive. +SET parallel_setup_cost = 0; +SET parallel_tuple_cost = 0; +SET min_parallel_table_scan_size = 0; +SET max_parallel_workers_per_gather = 2; + +CREATE TABLE test_upper_paths_hook_t (a int, b int) + WITH (autovacuum_enabled = false); +INSERT INTO test_upper_paths_hook_t +SELECT g % 100, g % 100 FROM generate_series(1, 20000) g; + +-- Extended statistics give the multi-column key a usable estimate. +CREATE STATISTICS test_upper_paths_hook_nd (ndistinct) +ON a, b FROM test_upper_paths_hook_t; +ANALYZE test_upper_paths_hook_t; + +-- Planning this UNION creates the partial setop upper rel. +EXPLAIN (COSTS OFF) +SELECT a, b FROM test_upper_paths_hook_t +UNION +SELECT a, b FROM test_upper_paths_hook_t; + +SELECT test_upper_paths_hook_seen(); +SELECT test_upper_paths_hook_input_null(); +SELECT test_upper_paths_hook_output_seen(); + +-- The partial path is still built when statistics are unreliable, so the +-- hook receives the stage. Whether it is chosen depends on the memory-aware +-- cost; for this small table the worst-case table fits in hash_mem, so it is +-- chosen. +SELECT test_upper_paths_hook_reset(); +CREATE TABLE test_upper_paths_hook_nostat (a int, b int) + WITH (autovacuum_enabled = false); +INSERT INTO test_upper_paths_hook_nostat +SELECT g % 100, g % 100 FROM generate_series(1, 20000) g; +EXPLAIN (COSTS OFF) +SELECT a, b FROM test_upper_paths_hook_nostat +UNION +SELECT a, b FROM test_upper_paths_hook_nostat; +SELECT test_upper_paths_hook_seen(); +DROP TABLE test_upper_paths_hook_nostat; + +-- A query without a set operation must not trigger the hook. +SELECT test_upper_paths_hook_reset(); +SELECT count(*) FROM test_upper_paths_hook_t; +SELECT test_upper_paths_hook_seen(); + +DROP TABLE test_upper_paths_hook_t; + +RESET parallel_setup_cost; +RESET parallel_tuple_cost; +RESET min_parallel_table_scan_size; +RESET max_parallel_workers_per_gather; diff --git a/src/test/modules/test_upper_paths_hook/test_upper_paths_hook.c b/src/test/modules/test_upper_paths_hook/test_upper_paths_hook.c new file mode 100644 index 0000000..9b9b3bd --- /dev/null +++ b/src/test/modules/test_upper_paths_hook/test_upper_paths_hook.c @@ -0,0 +1,79 @@ +/*-------------------------------------------------------------------------- + * + * test_upper_paths_hook.c + * Test that create_upper_paths_hook receives UPPERREL_PARTIAL_SETOP. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/modules/test_upper_paths_hook/test_upper_paths_hook.c + * + * ------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "fmgr.h" +#include "optimizer/planner.h" + +PG_MODULE_MAGIC; + +static create_upper_paths_hook_type prev_create_upper_paths_hook = NULL; +static bool saw_partial_setop = false; +static bool saw_input_null = false; +static bool saw_output_rel = false; + +static void +test_upper_paths_hook(PlannerInfo *root, UpperRelationKind stage, + RelOptInfo *input_rel, RelOptInfo *output_rel, + void *extra) +{ + if (stage == UPPERREL_PARTIAL_SETOP) + { + saw_partial_setop = true; + saw_input_null = (input_rel == NULL); + saw_output_rel = (output_rel != NULL); + } + + if (prev_create_upper_paths_hook) + prev_create_upper_paths_hook(root, stage, input_rel, output_rel, + extra); +} + +void +_PG_init(void) +{ + prev_create_upper_paths_hook = create_upper_paths_hook; + create_upper_paths_hook = test_upper_paths_hook; +} + +PG_FUNCTION_INFO_V1(test_upper_paths_hook_seen); +Datum +test_upper_paths_hook_seen(PG_FUNCTION_ARGS) +{ + PG_RETURN_BOOL(saw_partial_setop); +} + +PG_FUNCTION_INFO_V1(test_upper_paths_hook_input_null); +Datum +test_upper_paths_hook_input_null(PG_FUNCTION_ARGS) +{ + PG_RETURN_BOOL(saw_input_null); +} + +PG_FUNCTION_INFO_V1(test_upper_paths_hook_output_seen); +Datum +test_upper_paths_hook_output_seen(PG_FUNCTION_ARGS) +{ + PG_RETURN_BOOL(saw_output_rel); +} + +PG_FUNCTION_INFO_V1(test_upper_paths_hook_reset); +Datum +test_upper_paths_hook_reset(PG_FUNCTION_ARGS) +{ + saw_partial_setop = false; + saw_input_null = false; + saw_output_rel = false; + PG_RETURN_VOID(); +} diff --git a/src/test/regress/expected/union.out b/src/test/regress/expected/union.out index 2495351..bf15eb9 100644 --- a/src/test/regress/expected/union.out +++ b/src/test/regress/expected/union.out @@ -1804,3 +1804,326 @@ join (select ten from tenk1 union select ten from onek) s on s.ten = t.unique1; Index Cond: (unique1 = tenk1.ten) (8 rows) +-- +-- Parallel partial deduplication for UNION +-- +-- Force parallel paths to be cheap and available on the small regression +-- tables. +-- +set parallel_setup_cost = 0; +set parallel_tuple_cost = 0; +set min_parallel_table_scan_size = 0; +set max_parallel_workers_per_gather = 2; +-- With an index-provided order, MergeAppend + Unique still wins. +explain (costs off) +select unique1 from tenk1 union select unique1 from tenk1; + QUERY PLAN +------------------------------------------------------------------ + Unique + -> Merge Append + Sort Key: tenk1.unique1 + -> Index Only Scan using tenk1_unique1 on tenk1 + -> Index Only Scan using tenk1_unique1 on tenk1 tenk1_1 +(5 rows) + +-- The same holds for ORDER BY + LIMIT, which can terminate early. +explain (costs off) +select unique1 from tenk1 union select unique1 from tenk1 +order by 1 limit 10; + QUERY PLAN +------------------------------------------------------------------------ + Limit + -> Unique + -> Merge Append + Sort Key: tenk1.unique1 + -> Index Only Scan using tenk1_unique1 on tenk1 + -> Index Only Scan using tenk1_unique1 on tenk1 tenk1_1 +(6 rows) + +create table union_par_nostat (a int, b int) with (autovacuum_enabled = false); +insert into union_par_nostat select g % 100, g % 100 from generate_series(1, 20000) g; +-- Grouped and DISTINCT children: the current planner does not expose +-- partial paths for these child shapes, so no union-level worker dedup path +-- is built. Keep the plan and result coverage for them. +explain (costs off) +select a from (select a from union_par_nostat group by a) s +union select a from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a + -> Append + -> Finalize HashAggregate + Group Key: union_par_nostat.a + -> Gather + Workers Planned: 2 + -> Partial HashAggregate + Group Key: union_par_nostat.a + -> Parallel Seq Scan on union_par_nostat + -> Gather + Workers Planned: 2 + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 +(13 rows) + +select count(*) from + (select a as x from (select a from union_par_nostat group by a) s + union select a from union_par_nostat) s; + count +------- + 100 +(1 row) + +explain (costs off) +select distinct a from union_par_nostat +union select a from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a + -> Append + -> HashAggregate + Group Key: union_par_nostat.a + -> Gather + Workers Planned: 2 + -> HashAggregate + Group Key: union_par_nostat.a + -> Parallel Seq Scan on union_par_nostat + -> Gather + Workers Planned: 2 + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 +(13 rows) + +select count(*) from + (select distinct a as x from union_par_nostat + union select a from union_par_nostat) s; + count +------- + 100 +(1 row) + +-- A child that is itself a set operation: likewise no partial path is +-- exposed, so this checks the current plan shape and result rather than the +-- worker-side dedup path. +explain (costs off) +select a from (select a from union_par_nostat + intersect select a from union_par_nostat) s +union select a from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a + -> Append + -> HashSetOp Intersect + -> Gather + Workers Planned: 2 + -> Parallel Seq Scan on union_par_nostat + -> Gather + Workers Planned: 2 + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 + -> Gather + Workers Planned: 2 + -> Parallel Seq Scan on union_par_nostat union_par_nostat_2 +(13 rows) + +select count(*) from + (select a as x from (select a from union_par_nostat + intersect select a from union_par_nostat) s + union select a from union_par_nostat) s; + count +------- + 100 +(1 row) + +-- No statistics: the estimate is unreliable, but the worst-case hash table +-- (one group per input row) fits in hash_mem, so the worker-side path is +-- still considered. +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Gather + Workers Planned: 2 + -> HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Parallel Append + -> Parallel Seq Scan on union_par_nostat + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 +(9 rows) + +-- With a small work_mem the worst-case hash table does not fit, so the +-- worker-side path is rejected. +set work_mem = '64kB'; +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------------- + Unique + -> Merge Append + Sort Key: union_par_nostat.a, union_par_nostat.b + -> Gather Merge + Workers Planned: 2 + -> Sort + Sort Key: union_par_nostat.a, union_par_nostat.b + -> Parallel Seq Scan on union_par_nostat + -> Gather Merge + Workers Planned: 2 + -> Sort + Sort Key: union_par_nostat_1.a, union_par_nostat_1.b + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 +(13 rows) + +reset work_mem; +create statistics union_par_stats (ndistinct) on a, b from union_par_nostat; +analyze union_par_nostat; +-- Extended statistics give a usable ndistinct estimate for the multi-column +-- key, so the worker-side dedup path is chosen. +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Gather + Workers Planned: 2 + -> HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Parallel Append + -> Parallel Seq Scan on union_par_nostat + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 +(9 rows) + +-- Execute the plan and check the result. +select count(*) from + (select a, b from union_par_nostat union select a, b from union_par_nostat) s; + count +------- + 100 +(1 row) + +-- Multi-way UNION is flattened into a single partial-dedup path. +explain (costs off) +select a, b from union_par_nostat +union select a, b from union_par_nostat +union select a, b from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Gather + Workers Planned: 2 + -> HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Parallel Append + -> Parallel Seq Scan on union_par_nostat + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 + -> Parallel Seq Scan on union_par_nostat union_par_nostat_2 +(10 rows) + +-- A nested UNION ALL child is flattened into the parent UNION. +explain (costs off) +select a, b from union_par_nostat +union (select a, b from union_par_nostat union all select a, b from union_par_nostat); + QUERY PLAN +---------------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Gather + Workers Planned: 2 + -> HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Parallel Append + -> Parallel Seq Scan on union_par_nostat + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 + -> Parallel Seq Scan on union_par_nostat union_par_nostat_2 +(10 rows) + +-- A near-unique multi-column key without extended statistics must not use +-- the worker-side dedup path: the 10% correlation clamp makes the estimate +-- unreliable. +create table union_par_unique (a int, b int); +insert into union_par_unique +select g / 1000, g % 1000 from generate_series(1, 200000) g; +analyze union_par_unique; +explain (costs off) +select a, b from union_par_unique union select a, b from union_par_unique; + QUERY PLAN +---------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_unique.a, union_par_unique.b + -> Gather + Workers Planned: 2 + -> Parallel Append + -> Parallel Seq Scan on union_par_unique + -> Parallel Seq Scan on union_par_unique union_par_unique_1 +(7 rows) + +drop table union_par_unique; +-- With enable_hashagg off, cost_agg's disabled_nodes penalty prevents the +-- hash-aggregate paths from being chosen. +set enable_hashagg = off; +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------------- + Unique + -> Merge Append + Sort Key: union_par_nostat.a, union_par_nostat.b + -> Gather Merge + Workers Planned: 2 + -> Sort + Sort Key: union_par_nostat.a, union_par_nostat.b + -> Parallel Seq Scan on union_par_nostat + -> Gather Merge + Workers Planned: 2 + -> Sort + Sort Key: union_par_nostat_1.a, union_par_nostat_1.b + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 +(13 rows) + +reset enable_hashagg; +-- Without parallel append, the path must still be correct. +set enable_parallel_append = off; +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + QUERY PLAN +---------------------------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Gather + Workers Planned: 2 + -> HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Append + -> Parallel Seq Scan on union_par_nostat + -> Parallel Seq Scan on union_par_nostat union_par_nostat_1 +(9 rows) + +-- Execute the plan and check the result. +select count(*) from + (select a, b from union_par_nostat union select a, b from union_par_nostat) s; + count +------- + 100 +(1 row) + +reset enable_parallel_append; +-- With no workers, the serial path is used. +set max_parallel_workers_per_gather = 0; +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + QUERY PLAN +------------------------------------------------------------- + HashAggregate + Group Key: union_par_nostat.a, union_par_nostat.b + -> Append + -> Seq Scan on union_par_nostat + -> Seq Scan on union_par_nostat union_par_nostat_1 +(5 rows) + +drop table union_par_nostat; +reset parallel_setup_cost; +reset parallel_tuple_cost; +reset min_parallel_table_scan_size; +reset max_parallel_workers_per_gather; diff --git a/src/test/regress/sql/union.sql b/src/test/regress/sql/union.sql index 8fce452..74059b7 100644 --- a/src/test/regress/sql/union.sql +++ b/src/test/regress/sql/union.sql @@ -710,3 +710,131 @@ select null::int[] union all select null::int[] union all select null::bigint[]; explain (costs off) select * from tenk1 t join (select ten from tenk1 union select ten from onek) s on s.ten = t.unique1; + +-- +-- Parallel partial deduplication for UNION +-- +-- Force parallel paths to be cheap and available on the small regression +-- tables. +-- +set parallel_setup_cost = 0; +set parallel_tuple_cost = 0; +set min_parallel_table_scan_size = 0; +set max_parallel_workers_per_gather = 2; + +-- With an index-provided order, MergeAppend + Unique still wins. +explain (costs off) +select unique1 from tenk1 union select unique1 from tenk1; + +-- The same holds for ORDER BY + LIMIT, which can terminate early. +explain (costs off) +select unique1 from tenk1 union select unique1 from tenk1 +order by 1 limit 10; + +create table union_par_nostat (a int, b int) with (autovacuum_enabled = false); +insert into union_par_nostat select g % 100, g % 100 from generate_series(1, 20000) g; + +-- Grouped and DISTINCT children: the current planner does not expose +-- partial paths for these child shapes, so no union-level worker dedup path +-- is built. Keep the plan and result coverage for them. +explain (costs off) +select a from (select a from union_par_nostat group by a) s +union select a from union_par_nostat; +select count(*) from + (select a as x from (select a from union_par_nostat group by a) s + union select a from union_par_nostat) s; + +explain (costs off) +select distinct a from union_par_nostat +union select a from union_par_nostat; +select count(*) from + (select distinct a as x from union_par_nostat + union select a from union_par_nostat) s; + +-- A child that is itself a set operation: likewise no partial path is +-- exposed, so this checks the current plan shape and result rather than the +-- worker-side dedup path. +explain (costs off) +select a from (select a from union_par_nostat + intersect select a from union_par_nostat) s +union select a from union_par_nostat; +select count(*) from + (select a as x from (select a from union_par_nostat + intersect select a from union_par_nostat) s + union select a from union_par_nostat) s; + +-- No statistics: the estimate is unreliable, but the worst-case hash table +-- (one group per input row) fits in hash_mem, so the worker-side path is +-- still considered. +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + +-- With a small work_mem the worst-case hash table does not fit, so the +-- worker-side path is rejected. +set work_mem = '64kB'; +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; +reset work_mem; + +create statistics union_par_stats (ndistinct) on a, b from union_par_nostat; +analyze union_par_nostat; + +-- Extended statistics give a usable ndistinct estimate for the multi-column +-- key, so the worker-side dedup path is chosen. +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + +-- Execute the plan and check the result. +select count(*) from + (select a, b from union_par_nostat union select a, b from union_par_nostat) s; + +-- Multi-way UNION is flattened into a single partial-dedup path. +explain (costs off) +select a, b from union_par_nostat +union select a, b from union_par_nostat +union select a, b from union_par_nostat; + +-- A nested UNION ALL child is flattened into the parent UNION. +explain (costs off) +select a, b from union_par_nostat +union (select a, b from union_par_nostat union all select a, b from union_par_nostat); + +-- A near-unique multi-column key without extended statistics must not use +-- the worker-side dedup path: the 10% correlation clamp makes the estimate +-- unreliable. +create table union_par_unique (a int, b int); +insert into union_par_unique +select g / 1000, g % 1000 from generate_series(1, 200000) g; +analyze union_par_unique; +explain (costs off) +select a, b from union_par_unique union select a, b from union_par_unique; +drop table union_par_unique; + +-- With enable_hashagg off, cost_agg's disabled_nodes penalty prevents the +-- hash-aggregate paths from being chosen. +set enable_hashagg = off; +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; +reset enable_hashagg; + +-- Without parallel append, the path must still be correct. +set enable_parallel_append = off; +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + +-- Execute the plan and check the result. +select count(*) from + (select a, b from union_par_nostat union select a, b from union_par_nostat) s; +reset enable_parallel_append; + +-- With no workers, the serial path is used. +set max_parallel_workers_per_gather = 0; +explain (costs off) +select a, b from union_par_nostat union select a, b from union_par_nostat; + +drop table union_par_nostat; +reset parallel_setup_cost; +reset parallel_tuple_cost; +reset min_parallel_table_scan_size; +reset max_parallel_workers_per_gather; + -- 2.43.0