From cbceb8a4c7f86e6435620270c2b7b984bdcedd0a Mon Sep 17 00:00:00 2001
From: William Bernbaum <wbernbaum@dwdev.com>
Date: Wed, 26 Aug 2026 09:11:08 -0700
Subject: [PATCH v2 06/17] Fold in a deduplication at every join level

Joining deduplicated rows to another relation produces duplicates for
the next level to remove. Deduplicating twice is harmless.

This prevents duplicates from accumulating along a chain of to-many
joins.
---
 src/backend/optimizer/path/joinrels.c         |  99 +++-
 src/test/regress/expected/aggregates.out      |   5 +-
 src/test/regress/expected/eager_aggregate.out | 561 ++++++++++++++++++
 .../regress/expected/incremental_sort.out     |   6 +-
 src/test/regress/sql/aggregates.sql           |   5 +-
 src/test/regress/sql/eager_aggregate.sql      | 228 +++++++
 src/test/regress/sql/incremental_sort.sql     |   6 +-
 7 files changed, 902 insertions(+), 8 deletions(-)

diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 443e2dca7c0..fb35c0bcfd5 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -40,6 +40,10 @@ static bool restriction_is_constant_false(List *restrictlist,
 static void make_grouped_join_rel(PlannerInfo *root, RelOptInfo *rel1,
 								  RelOptInfo *rel2, RelOptInfo *joinrel,
 								  SpecialJoinInfo *sjinfo, List *restrictlist);
+static void make_deduplicated_join_rel(PlannerInfo *root, RelOptInfo *rel1,
+									   RelOptInfo *rel2, RelOptInfo *joinrel,
+									   SpecialJoinInfo *sjinfo,
+									   List *restrictlist);
 static void populate_joinrel_with_paths(PlannerInfo *root, RelOptInfo *rel1,
 										RelOptInfo *rel2, RelOptInfo *joinrel,
 										SpecialJoinInfo *sjinfo, List *restrictlist);
@@ -925,13 +929,20 @@ make_grouped_join_rel(PlannerInfo *root, RelOptInfo *rel1,
 	Relids		apply_agg_at;
 
 	/*
-	 * If there are no aggregate expressions or grouping expressions, eager
-	 * aggregation is not possible.
+	 * If there are no grouping expressions, eager aggregation is not
+	 * possible.
 	 */
-	if (root->agg_clause_list == NIL ||
-		root->group_expr_list == NIL)
+	if (root->group_expr_list == NIL)
 		return;
 
+	/* A deduplication composes down the join tree differently */
+	if (root->eager_agg_mode == EAGER_AGG_DEDUP)
+	{
+		make_deduplicated_join_rel(root, rel1, rel2, joinrel, sjinfo,
+								   restrictlist);
+		return;
+	}
+
 	/* Retrieve the grouped relations for the two input rels */
 	grouped_rel1 = rel1->grouped_rel;
 	grouped_rel2 = rel2->grouped_rel;
@@ -1061,6 +1072,86 @@ make_grouped_join_rel(PlannerInfo *root, RelOptInfo *rel1,
 								restrictlist);
 }
 
+/*
+ * make_deduplicated_join_rel
+ *	  Build the deduplicated variant of the given join relation.
+ *
+ * A join reintroduces the duplicates, and deduplication is idempotent, so one
+ * is folded in at every level.
+ */
+static void
+make_deduplicated_join_rel(PlannerInfo *root, RelOptInfo *rel1,
+						   RelOptInfo *rel2, RelOptInfo *joinrel,
+						   SpecialJoinInfo *sjinfo, List *restrictlist)
+{
+	RelOptInfo *grouped_rel;
+	RelOptInfo *grouped_rel1 = rel1->grouped_rel;
+	RelOptInfo *grouped_rel2 = rel2->grouped_rel;
+	RelOptInfo *joined;
+	bool		rel1_empty;
+	bool		rel2_empty;
+
+	rel1_empty = (grouped_rel1 == NULL || IS_DUMMY_REL(grouped_rel1));
+	rel2_empty = (grouped_rel2 == NULL || IS_DUMMY_REL(grouped_rel2));
+
+	/* Find or construct the deduplicated relation for this joinrel */
+	grouped_rel = joinrel->grouped_rel;
+	if (grouped_rel == NULL)
+	{
+		RelAggInfo *agg_info;
+
+		agg_info = create_rel_agg_info(root, joinrel, true);
+		if (agg_info == NULL || !agg_info->agg_useful)
+			return;
+
+		grouped_rel = build_grouped_rel(root, joinrel);
+		grouped_rel->reltarget = agg_info->target;
+		grouped_rel->rows = agg_info->grouped_rows;
+
+		/* Every level is its own placement */
+		agg_info->apply_agg_at = bms_copy(joinrel->relids);
+
+		grouped_rel->agg_info = agg_info;
+		joinrel->grouped_rel = grouped_rel;
+	}
+
+	Assert(IS_GROUPED_REL(grouped_rel));
+
+	/* We may have already proven this relation to be dummy. */
+	if (IS_DUMMY_REL(grouped_rel))
+		return;
+
+	/* generate_grouped_paths() covers the case of two plain inputs */
+	if (rel1_empty && rel2_empty)
+		return;
+
+	/*
+	 * Join the inputs into a scratch relation, preferring the deduplicated
+	 * variant of each side, then deduplicate the result.  Using both
+	 * deduplicated variants at once is safe: every join key is a grouping
+	 * key, so merged rows match the same rows on the other side.
+	 */
+	joined = build_grouped_rel(root, joinrel);
+	joined->reltarget = grouped_rel->agg_info->agg_input;
+	set_joinrel_size_estimates(root, joined,
+							   rel1_empty ? rel1 : grouped_rel1,
+							   rel2_empty ? rel2 : grouped_rel2,
+							   sjinfo, restrictlist);
+
+	populate_joinrel_with_paths(root,
+								rel1_empty ? rel1 : grouped_rel1,
+								rel2_empty ? rel2 : grouped_rel2,
+								joined,
+								sjinfo,
+								restrictlist);
+
+	if (joined->pathlist == NIL)
+		return;
+
+	set_cheapest(joined);
+	generate_grouped_paths(root, grouped_rel, joined);
+}
+
 /*
  * populate_joinrel_with_paths
  *	  Add paths to the given joinrel for given pair of joining relations. The
diff --git a/src/test/regress/expected/aggregates.out b/src/test/regress/expected/aggregates.out
index 7d07619956f..7002384f8fa 100644
--- a/src/test/regress/expected/aggregates.out
+++ b/src/test/regress/expected/aggregates.out
@@ -3489,7 +3489,9 @@ GROUP BY c1.w, c1.z;
 
 -- Pathkeys, built in a subtree, can be used to optimize GROUP-BY clause
 -- ordering.  Also, here we check that it doesn't depend on the initial clause
--- order in the GROUP-BY list.
+-- order in the GROUP-BY list.  Eager aggregation is disabled so that the
+-- pathkeys of the join are what reaches the grouping step.
+SET enable_eager_aggregate = off;
 EXPLAIN (COSTS OFF)
 SELECT c1.y,c1.x FROM group_agg_pk c1
   JOIN group_agg_pk c2
@@ -3538,6 +3540,7 @@ GROUP BY c1.y,c2.x,c1.x;
                            Disabled: true
 (15 rows)
 
+RESET enable_eager_aggregate;
 RESET enable_nestloop;
 RESET enable_hashjoin;
 DROP TABLE group_agg_pk;
diff --git a/src/test/regress/expected/eager_aggregate.out b/src/test/regress/expected/eager_aggregate.out
index 091ae48a92b..d146ff08c43 100644
--- a/src/test/regress/expected/eager_aggregate.out
+++ b/src/test/regress/expected/eager_aggregate.out
@@ -1838,3 +1838,564 @@ GROUP BY t1.x ORDER BY t1.x;
 RESET geqo;
 RESET geqo_threshold;
 DROP TABLE eager_agg_tab_ml;
+--
+-- Test eager aggregation for queries with no aggregates, where what is
+-- pushed down is a plain deduplication
+--
+CREATE TABLE eager_distinct_t1 (id int, val int);
+CREATE TABLE eager_distinct_t2 (id int, t1_id int, flag bool);
+INSERT INTO eager_distinct_t1 SELECT i, i FROM generate_series(1, 10) i;
+INSERT INTO eager_distinct_t2
+  SELECT i, ((i - 1) / 100) + 1, i % 2 = 0 FROM generate_series(1, 1000) i;
+ANALYZE eager_distinct_t1;
+ANALYZE eager_distinct_t2;
+-- Deduplicate the to-many side before the join, so the join produces only
+-- the rows DISTINCT keeps.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+                              QUERY PLAN                               
+-----------------------------------------------------------------------
+ Sort
+   Output: t1.id
+   Sort Key: t1.id
+   ->  HashAggregate
+         Output: t1.id
+         Group Key: t1.id
+         ->  Hash Join
+               Output: t1.id
+               Hash Cond: (t1.id = t2.t1_id)
+               ->  Seq Scan on public.eager_distinct_t1 t1
+                     Output: t1.id, t1.val
+               ->  Hash
+                     Output: t2.t1_id
+                     ->  HashAggregate
+                           Output: t2.t1_id
+                           Group Key: t2.t1_id
+                           ->  Seq Scan on public.eager_distinct_t2 t2
+                                 Output: t2.id, t2.t1_id, t2.flag
+(18 rows)
+
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+ id 
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+-- The same query without eager aggregation, for comparison
+SET enable_eager_aggregate TO off;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Sort
+   Output: t1.id
+   Sort Key: t1.id
+   ->  HashAggregate
+         Output: t1.id
+         Group Key: t1.id
+         ->  Hash Join
+               Output: t1.id
+               Hash Cond: (t2.t1_id = t1.id)
+               ->  Seq Scan on public.eager_distinct_t2 t2
+                     Output: t2.id, t2.t1_id, t2.flag
+               ->  Hash
+                     Output: t1.id
+                     ->  Seq Scan on public.eager_distinct_t1 t1
+                           Output: t1.id
+(15 rows)
+
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+ id 
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+RESET enable_eager_aggregate;
+-- Produce results with sorting deduplication
+SET enable_hashagg TO off;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
+ Sort
+   Output: t1.id
+   Sort Key: t1.id
+   ->  GroupAggregate
+         Output: t1.id
+         Group Key: t1.id
+         ->  Sort
+               Output: t1.id
+               Sort Key: t1.id
+               ->  Hash Join
+                     Output: t1.id
+                     Hash Cond: (t2.t1_id = t1.id)
+                     ->  GroupAggregate
+                           Output: t2.t1_id
+                           Group Key: t2.t1_id
+                           ->  Sort
+                                 Output: t2.t1_id
+                                 Sort Key: t2.t1_id
+                                 ->  Seq Scan on public.eager_distinct_t2 t2
+                                       Output: t2.t1_id
+                     ->  Hash
+                           Output: t1.id
+                           ->  Seq Scan on public.eager_distinct_t1 t1
+                                 Output: t1.id
+(24 rows)
+
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+ id 
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+RESET enable_hashagg;
+-- Deduplication is not possible when a column of the to-many side is
+-- observable, since its rows are then not interchangeable.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id, t2.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id, t2.id;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Unique
+   Output: t1.id, t2.id
+   ->  Sort
+         Output: t1.id, t2.id
+         Sort Key: t1.id, t2.id
+         ->  Hash Join
+               Output: t1.id, t2.id
+               Hash Cond: (t2.t1_id = t1.id)
+               ->  Seq Scan on public.eager_distinct_t2 t2
+                     Output: t2.id, t2.t1_id, t2.flag
+               ->  Hash
+                     Output: t1.id
+                     ->  Seq Scan on public.eager_distinct_t1 t1
+                           Output: t1.id
+(14 rows)
+
+-- Declined for DISTINCT ON too, which keeps a particular row from each
+-- group, even though only the driver side is projected here
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT ON (t1.id) t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id, t2.id;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Unique
+   Output: t1.id, t2.id
+   ->  Sort
+         Output: t1.id, t2.id
+         Sort Key: t1.id, t2.id
+         ->  Hash Join
+               Output: t1.id, t2.id
+               Hash Cond: (t2.t1_id = t1.id)
+               ->  Seq Scan on public.eager_distinct_t2 t2
+                     Output: t2.id, t2.t1_id, t2.flag
+               ->  Hash
+                     Output: t1.id
+                     ->  Seq Scan on public.eager_distinct_t1 t1
+                           Output: t1.id
+(14 rows)
+
+-- A qual on the to-many side is applied before the deduplication
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ WHERE t2.flag
+ORDER BY t1.id;
+                              QUERY PLAN                               
+-----------------------------------------------------------------------
+ Sort
+   Output: t1.id
+   Sort Key: t1.id
+   ->  HashAggregate
+         Output: t1.id
+         Group Key: t1.id
+         ->  Hash Join
+               Output: t1.id
+               Hash Cond: (t1.id = t2.t1_id)
+               ->  Seq Scan on public.eager_distinct_t1 t1
+                     Output: t1.id, t1.val
+               ->  Hash
+                     Output: t2.t1_id
+                     ->  HashAggregate
+                           Output: t2.t1_id
+                           Group Key: t2.t1_id
+                           ->  Seq Scan on public.eager_distinct_t2 t2
+                                 Output: t2.id, t2.t1_id, t2.flag
+                                 Filter: t2.flag
+(19 rows)
+
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ WHERE t2.flag
+ORDER BY t1.id;
+ id 
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+-- GROUP BY with no aggregates behaves the same way as DISTINCT
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+GROUP BY t1.id ORDER BY t1.id;
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
+ Group
+   Output: t1.id
+   Group Key: t1.id
+   ->  GroupAggregate
+         Output: t1.id
+         Group Key: t1.id
+         ->  Sort
+               Output: t1.id
+               Sort Key: t1.id
+               ->  Hash Join
+                     Output: t1.id
+                     Hash Cond: (t1.id = t2.t1_id)
+                     ->  Seq Scan on public.eager_distinct_t1 t1
+                           Output: t1.id, t1.val
+                     ->  Hash
+                           Output: t2.t1_id
+                           ->  HashAggregate
+                                 Output: t2.t1_id
+                                 Group Key: t2.t1_id
+                                 ->  Seq Scan on public.eager_distinct_t2 t2
+                                       Output: t2.id, t2.t1_id, t2.flag
+(21 rows)
+
+SELECT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+GROUP BY t1.id ORDER BY t1.id;
+ id 
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+DROP TABLE eager_distinct_t1;
+DROP TABLE eager_distinct_t2;
+--
+-- Test that the deduplication has its own usefulness threshold, separate
+-- from the one that governs aggregate pushdown
+--
+CREATE TABLE eager_distinct_g1 (id int);
+CREATE TABLE eager_distinct_g2 (id int, g1_id int);
+-- four rows per driver row, which is above min_eager_distinct_group_size and
+-- below min_eager_agg_group_size
+INSERT INTO eager_distinct_g1 SELECT i FROM generate_series(1, 10) i;
+INSERT INTO eager_distinct_g2
+  SELECT i, ((i - 1) / 4) + 1 FROM generate_series(1, 40) i;
+ANALYZE eager_distinct_g1;
+ANALYZE eager_distinct_g2;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT g1.id
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+ORDER BY g1.id;
+                              QUERY PLAN                               
+-----------------------------------------------------------------------
+ Sort
+   Output: g1.id
+   Sort Key: g1.id
+   ->  HashAggregate
+         Output: g1.id
+         Group Key: g1.id
+         ->  Hash Join
+               Output: g1.id
+               Hash Cond: (g1.id = g2.g1_id)
+               ->  Seq Scan on public.eager_distinct_g1 g1
+                     Output: g1.id
+               ->  Hash
+                     Output: g2.g1_id
+                     ->  HashAggregate
+                           Output: g2.g1_id
+                           Group Key: g2.g1_id
+                           ->  Seq Scan on public.eager_distinct_g2 g2
+                                 Output: g2.id, g2.g1_id
+(18 rows)
+
+-- Raising the threshold past the group size declines the pushdown
+SET min_eager_distinct_group_size TO 8;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT g1.id
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+ORDER BY g1.id;
+                           QUERY PLAN                            
+-----------------------------------------------------------------
+ Sort
+   Output: g1.id
+   Sort Key: g1.id
+   ->  HashAggregate
+         Output: g1.id
+         Group Key: g1.id
+         ->  Hash Join
+               Output: g1.id
+               Hash Cond: (g2.g1_id = g1.id)
+               ->  Seq Scan on public.eager_distinct_g2 g2
+                     Output: g2.id, g2.g1_id
+               ->  Hash
+                     Output: g1.id
+                     ->  Seq Scan on public.eager_distinct_g1 g1
+                           Output: g1.id
+(15 rows)
+
+-- ... without changing the answer
+SELECT DISTINCT g1.id
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+ORDER BY g1.id;
+ id 
+----
+  1
+  2
+  3
+  4
+  5
+  6
+  7
+  8
+  9
+ 10
+(10 rows)
+
+RESET min_eager_distinct_group_size;
+-- The aggregate threshold does not govern the deduplication
+SET min_eager_agg_group_size TO 1000;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT g1.id
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+ORDER BY g1.id;
+                              QUERY PLAN                               
+-----------------------------------------------------------------------
+ Sort
+   Output: g1.id
+   Sort Key: g1.id
+   ->  HashAggregate
+         Output: g1.id
+         Group Key: g1.id
+         ->  Hash Join
+               Output: g1.id
+               Hash Cond: (g1.id = g2.g1_id)
+               ->  Seq Scan on public.eager_distinct_g1 g1
+                     Output: g1.id
+               ->  Hash
+                     Output: g2.g1_id
+                     ->  HashAggregate
+                           Output: g2.g1_id
+                           Group Key: g2.g1_id
+                           ->  Seq Scan on public.eager_distinct_g2 g2
+                                 Output: g2.id, g2.g1_id
+(18 rows)
+
+RESET min_eager_agg_group_size;
+-- ... nor does the deduplication threshold govern aggregates
+SET min_eager_distinct_group_size TO 1000;
+SET min_eager_agg_group_size TO 0;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT g1.id, max(g2.id)
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+GROUP BY g1.id ORDER BY g1.id;
+                              QUERY PLAN                               
+-----------------------------------------------------------------------
+ Finalize GroupAggregate
+   Output: g1.id, max(g2.id)
+   Group Key: g1.id
+   ->  Sort
+         Output: g1.id, (PARTIAL max(g2.id))
+         Sort Key: g1.id
+         ->  Hash Join
+               Output: g1.id, (PARTIAL max(g2.id))
+               Hash Cond: (g1.id = g2.g1_id)
+               ->  Seq Scan on public.eager_distinct_g1 g1
+                     Output: g1.id
+               ->  Hash
+                     Output: g2.g1_id, (PARTIAL max(g2.id))
+                     ->  Partial HashAggregate
+                           Output: g2.g1_id, PARTIAL max(g2.id)
+                           Group Key: g2.g1_id
+                           ->  Seq Scan on public.eager_distinct_g2 g2
+                                 Output: g2.id, g2.g1_id
+(18 rows)
+
+RESET min_eager_distinct_group_size;
+RESET min_eager_agg_group_size;
+DROP TABLE eager_distinct_g1;
+DROP TABLE eager_distinct_g2;
+--
+-- Test that on a chain of to-many joins the deduplication is applied at
+-- every hop, so that the duplicates never accumulate
+--
+CREATE TABLE eager_distinct_c1 (id int);
+CREATE TABLE eager_distinct_c2 (id int, c1_id int);
+CREATE TABLE eager_distinct_c3 (id int, c2_id int);
+INSERT INTO eager_distinct_c1 SELECT i FROM generate_series(1, 100) i;
+INSERT INTO eager_distinct_c2
+  SELECT i, ((i - 1) / 10) + 1 FROM generate_series(1, 1000) i;
+INSERT INTO eager_distinct_c3
+  SELECT i, ((i - 1) / 10) + 1 FROM generate_series(1, 10000) i;
+ANALYZE eager_distinct_c1;
+ANALYZE eager_distinct_c2;
+ANALYZE eager_distinct_c3;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT c1.id
+  FROM eager_distinct_c1 c1
+  JOIN eager_distinct_c2 c2 ON c2.c1_id = c1.id
+  JOIN eager_distinct_c3 c3 ON c3.c2_id = c2.id;
+                                    QUERY PLAN                                     
+-----------------------------------------------------------------------------------
+ HashAggregate
+   Output: c1.id
+   Group Key: c1.id
+   ->  Hash Join
+         Output: c1.id
+         Hash Cond: (c1.id = c2.c1_id)
+         ->  Seq Scan on public.eager_distinct_c1 c1
+               Output: c1.id
+         ->  Hash
+               Output: c2.c1_id
+               ->  HashAggregate
+                     Output: c2.c1_id
+                     Group Key: c2.c1_id
+                     ->  Hash Join
+                           Output: c2.c1_id
+                           Hash Cond: (c2.id = c3.c2_id)
+                           ->  Seq Scan on public.eager_distinct_c2 c2
+                                 Output: c2.id, c2.c1_id
+                           ->  Hash
+                                 Output: c3.c2_id
+                                 ->  HashAggregate
+                                       Output: c3.c2_id
+                                       Group Key: c3.c2_id
+                                       ->  Seq Scan on public.eager_distinct_c3 c3
+                                             Output: c3.id, c3.c2_id
+(25 rows)
+
+-- The result is 100 rows, so check it rather than printing it
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT c1.id
+    FROM eager_distinct_c1 c1
+    JOIN eager_distinct_c2 c2 ON c2.c1_id = c1.id
+    JOIN eager_distinct_c3 c3 ON c3.c2_id = c2.id) s;
+ count | sum  
+-------+------
+   100 | 5050
+(1 row)
+
+-- The same chain without eager aggregation, for comparison
+SET enable_eager_aggregate TO off;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT c1.id
+  FROM eager_distinct_c1 c1
+  JOIN eager_distinct_c2 c2 ON c2.c1_id = c1.id
+  JOIN eager_distinct_c3 c3 ON c3.c2_id = c2.id;
+                              QUERY PLAN                               
+-----------------------------------------------------------------------
+ HashAggregate
+   Output: c1.id
+   Group Key: c1.id
+   ->  Hash Join
+         Output: c1.id
+         Hash Cond: (c3.c2_id = c2.id)
+         ->  Seq Scan on public.eager_distinct_c3 c3
+               Output: c3.id, c3.c2_id
+         ->  Hash
+               Output: c1.id, c2.id
+               ->  Hash Join
+                     Output: c1.id, c2.id
+                     Hash Cond: (c2.c1_id = c1.id)
+                     ->  Seq Scan on public.eager_distinct_c2 c2
+                           Output: c2.id, c2.c1_id
+                     ->  Hash
+                           Output: c1.id
+                           ->  Seq Scan on public.eager_distinct_c1 c1
+                                 Output: c1.id
+(19 rows)
+
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT c1.id
+    FROM eager_distinct_c1 c1
+    JOIN eager_distinct_c2 c2 ON c2.c1_id = c1.id
+    JOIN eager_distinct_c3 c3 ON c3.c2_id = c2.id) s;
+ count | sum  
+-------+------
+   100 | 5050
+(1 row)
+
+RESET enable_eager_aggregate;
+DROP TABLE eager_distinct_c1;
+DROP TABLE eager_distinct_c2;
+DROP TABLE eager_distinct_c3;
diff --git a/src/test/regress/expected/incremental_sort.out b/src/test/regress/expected/incremental_sort.out
index 1e6e020fea8..bda6a9f208b 100644
--- a/src/test/regress/expected/incremental_sort.out
+++ b/src/test/regress/expected/incremental_sort.out
@@ -1517,7 +1517,10 @@ set parallel_tuple_cost=0;
 set parallel_setup_cost=0;
 set min_parallel_table_scan_size = 0;
 set min_parallel_index_scan_size = 0;
--- Parallel sort below join.
+-- Parallel sort below join.  The lateral side only multiplies rows, so eager
+-- deduplication would fold it into an existence check and this would stop
+-- being a test of where the sort goes.
+set enable_eager_aggregate = off;
 explain (costs off) select distinct sub.unique1, stringu1
 from tenk1, lateral (select tenk1.unique1 from generate_series(1, 1000)) as sub;
                                 QUERY PLAN                                
@@ -1532,6 +1535,7 @@ from tenk1, lateral (select tenk1.unique1 from generate_series(1, 1000)) as sub;
          ->  Function Scan on generate_series
 (8 rows)
 
+reset enable_eager_aggregate;
 explain (costs off) select sub.unique1, stringu1
 from tenk1, lateral (select tenk1.unique1 from generate_series(1, 1000)) as sub
 order by 1, 2;
diff --git a/src/test/regress/sql/aggregates.sql b/src/test/regress/sql/aggregates.sql
index 91f8342166f..49f6deb9b07 100644
--- a/src/test/regress/sql/aggregates.sql
+++ b/src/test/regress/sql/aggregates.sql
@@ -1487,7 +1487,9 @@ GROUP BY c1.w, c1.z;
 
 -- Pathkeys, built in a subtree, can be used to optimize GROUP-BY clause
 -- ordering.  Also, here we check that it doesn't depend on the initial clause
--- order in the GROUP-BY list.
+-- order in the GROUP-BY list.  Eager aggregation is disabled so that the
+-- pathkeys of the join are what reaches the grouping step.
+SET enable_eager_aggregate = off;
 EXPLAIN (COSTS OFF)
 SELECT c1.y,c1.x FROM group_agg_pk c1
   JOIN group_agg_pk c2
@@ -1498,6 +1500,7 @@ SELECT c1.y,c1.x FROM group_agg_pk c1
   JOIN group_agg_pk c2
   ON c1.x = c2.x
 GROUP BY c1.y,c2.x,c1.x;
+RESET enable_eager_aggregate;
 
 RESET enable_nestloop;
 RESET enable_hashjoin;
diff --git a/src/test/regress/sql/eager_aggregate.sql b/src/test/regress/sql/eager_aggregate.sql
index 7bca9c524da..2c276d29f69 100644
--- a/src/test/regress/sql/eager_aggregate.sql
+++ b/src/test/regress/sql/eager_aggregate.sql
@@ -415,3 +415,231 @@ RESET geqo;
 RESET geqo_threshold;
 
 DROP TABLE eager_agg_tab_ml;
+
+
+--
+-- Test eager aggregation for queries with no aggregates, where what is
+-- pushed down is a plain deduplication
+--
+
+CREATE TABLE eager_distinct_t1 (id int, val int);
+CREATE TABLE eager_distinct_t2 (id int, t1_id int, flag bool);
+
+INSERT INTO eager_distinct_t1 SELECT i, i FROM generate_series(1, 10) i;
+INSERT INTO eager_distinct_t2
+  SELECT i, ((i - 1) / 100) + 1, i % 2 = 0 FROM generate_series(1, 1000) i;
+
+ANALYZE eager_distinct_t1;
+ANALYZE eager_distinct_t2;
+
+-- Deduplicate the to-many side before the join, so the join produces only
+-- the rows DISTINCT keeps.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+
+-- The same query without eager aggregation, for comparison
+SET enable_eager_aggregate TO off;
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+
+RESET enable_eager_aggregate;
+
+-- Produce results with sorting deduplication
+SET enable_hashagg TO off;
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id;
+
+RESET enable_hashagg;
+
+-- Deduplication is not possible when a column of the to-many side is
+-- observable, since its rows are then not interchangeable.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id, t2.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id, t2.id;
+
+-- Declined for DISTINCT ON too, which keeps a particular row from each
+-- group, even though only the driver side is projected here
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT ON (t1.id) t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ORDER BY t1.id, t2.id;
+
+-- A qual on the to-many side is applied before the deduplication
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ WHERE t2.flag
+ORDER BY t1.id;
+
+SELECT DISTINCT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+ WHERE t2.flag
+ORDER BY t1.id;
+
+-- GROUP BY with no aggregates behaves the same way as DISTINCT
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+GROUP BY t1.id ORDER BY t1.id;
+
+SELECT t1.id
+  FROM eager_distinct_t1 t1
+  JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
+GROUP BY t1.id ORDER BY t1.id;
+
+DROP TABLE eager_distinct_t1;
+DROP TABLE eager_distinct_t2;
+
+
+--
+-- Test that the deduplication has its own usefulness threshold, separate
+-- from the one that governs aggregate pushdown
+--
+
+CREATE TABLE eager_distinct_g1 (id int);
+CREATE TABLE eager_distinct_g2 (id int, g1_id int);
+
+-- four rows per driver row, which is above min_eager_distinct_group_size and
+-- below min_eager_agg_group_size
+INSERT INTO eager_distinct_g1 SELECT i FROM generate_series(1, 10) i;
+INSERT INTO eager_distinct_g2
+  SELECT i, ((i - 1) / 4) + 1 FROM generate_series(1, 40) i;
+
+ANALYZE eager_distinct_g1;
+ANALYZE eager_distinct_g2;
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT g1.id
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+ORDER BY g1.id;
+
+-- Raising the threshold past the group size declines the pushdown
+SET min_eager_distinct_group_size TO 8;
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT g1.id
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+ORDER BY g1.id;
+
+-- ... without changing the answer
+SELECT DISTINCT g1.id
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+ORDER BY g1.id;
+
+RESET min_eager_distinct_group_size;
+
+-- The aggregate threshold does not govern the deduplication
+SET min_eager_agg_group_size TO 1000;
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT g1.id
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+ORDER BY g1.id;
+
+RESET min_eager_agg_group_size;
+
+-- ... nor does the deduplication threshold govern aggregates
+SET min_eager_distinct_group_size TO 1000;
+SET min_eager_agg_group_size TO 0;
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT g1.id, max(g2.id)
+  FROM eager_distinct_g1 g1
+  JOIN eager_distinct_g2 g2 ON g2.g1_id = g1.id
+GROUP BY g1.id ORDER BY g1.id;
+
+RESET min_eager_distinct_group_size;
+RESET min_eager_agg_group_size;
+
+DROP TABLE eager_distinct_g1;
+DROP TABLE eager_distinct_g2;
+
+
+--
+-- Test that on a chain of to-many joins the deduplication is applied at
+-- every hop, so that the duplicates never accumulate
+--
+
+CREATE TABLE eager_distinct_c1 (id int);
+CREATE TABLE eager_distinct_c2 (id int, c1_id int);
+CREATE TABLE eager_distinct_c3 (id int, c2_id int);
+
+INSERT INTO eager_distinct_c1 SELECT i FROM generate_series(1, 100) i;
+INSERT INTO eager_distinct_c2
+  SELECT i, ((i - 1) / 10) + 1 FROM generate_series(1, 1000) i;
+INSERT INTO eager_distinct_c3
+  SELECT i, ((i - 1) / 10) + 1 FROM generate_series(1, 10000) i;
+
+ANALYZE eager_distinct_c1;
+ANALYZE eager_distinct_c2;
+ANALYZE eager_distinct_c3;
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT c1.id
+  FROM eager_distinct_c1 c1
+  JOIN eager_distinct_c2 c2 ON c2.c1_id = c1.id
+  JOIN eager_distinct_c3 c3 ON c3.c2_id = c2.id;
+
+-- The result is 100 rows, so check it rather than printing it
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT c1.id
+    FROM eager_distinct_c1 c1
+    JOIN eager_distinct_c2 c2 ON c2.c1_id = c1.id
+    JOIN eager_distinct_c3 c3 ON c3.c2_id = c2.id) s;
+
+-- The same chain without eager aggregation, for comparison
+SET enable_eager_aggregate TO off;
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT DISTINCT c1.id
+  FROM eager_distinct_c1 c1
+  JOIN eager_distinct_c2 c2 ON c2.c1_id = c1.id
+  JOIN eager_distinct_c3 c3 ON c3.c2_id = c2.id;
+
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT c1.id
+    FROM eager_distinct_c1 c1
+    JOIN eager_distinct_c2 c2 ON c2.c1_id = c1.id
+    JOIN eager_distinct_c3 c3 ON c3.c2_id = c2.id) s;
+
+RESET enable_eager_aggregate;
+
+DROP TABLE eager_distinct_c1;
+DROP TABLE eager_distinct_c2;
+DROP TABLE eager_distinct_c3;
diff --git a/src/test/regress/sql/incremental_sort.sql b/src/test/regress/sql/incremental_sort.sql
index bbe658a7588..a18617c0b01 100644
--- a/src/test/regress/sql/incremental_sort.sql
+++ b/src/test/regress/sql/incremental_sort.sql
@@ -240,9 +240,13 @@ set parallel_setup_cost=0;
 set min_parallel_table_scan_size = 0;
 set min_parallel_index_scan_size = 0;
 
--- Parallel sort below join.
+-- Parallel sort below join.  The lateral side only multiplies rows, so eager
+-- deduplication would fold it into an existence check and this would stop
+-- being a test of where the sort goes.
+set enable_eager_aggregate = off;
 explain (costs off) select distinct sub.unique1, stringu1
 from tenk1, lateral (select tenk1.unique1 from generate_series(1, 1000)) as sub;
+reset enable_eager_aggregate;
 explain (costs off) select sub.unique1, stringu1
 from tenk1, lateral (select tenk1.unique1 from generate_series(1, 1000)) as sub
 order by 1, 2;
