From 9a86dcccf1f2c9553f81b05892e6580ef8dba11e Mon Sep 17 00:00:00 2001 From: "Andrei V. Lepikhov" Date: Thu, 2 Apr 2026 15:35:16 +0200 Subject: [PATCH v2] Propagate LIMIT bound through the outer side of left and full joins. ExecSetTupleBound() passes the number of tuples needed down to a Sort, and others compatible below it, enabling a bounded top-N sort, but stops at any node that might discard or combine input rows. A join is such a node in general. But a left or full join never discards an outer row: each outer tuple produces at least one output tuple, either joined or null-extended. Such a join therefore needs no more outer tuples than it has to return, and the bound can be pushed to its outer side. This holds only if the join has no qual applied to its output, since that could filter rows. --- src/backend/executor/execProcnode.c | 13 ++ src/test/regress/expected/limit.out | 219 ++++++++++++++++++++++++++++ src/test/regress/sql/limit.sql | 106 ++++++++++++++ 3 files changed, 338 insertions(+) diff --git a/src/backend/executor/execProcnode.c b/src/backend/executor/execProcnode.c index 7c4c66e323f..7c9577d1a19 100644 --- a/src/backend/executor/execProcnode.c +++ b/src/backend/executor/execProcnode.c @@ -958,6 +958,19 @@ ExecSetTupleBound(int64 tuples_needed, PlanState *child_node) ExecSetTupleBound(tuples_needed, outerPlanState(child_node)); } + else if (IsA(child_node, NestLoopState) || + IsA(child_node, MergeJoinState) || IsA(child_node, HashJoinState)) + { + /* + * A left or full join can propagate the bound to the outer side, but + * only if it has no filtering qual (otherwise it might discard rows). + */ + JoinState *jstate = (JoinState *) child_node; + + if ((jstate->jointype == JOIN_LEFT || jstate->jointype == JOIN_FULL) + && jstate->ps.qual == NULL) + ExecSetTupleBound(tuples_needed, outerPlanState(child_node)); + } /* * In principle we could descend through any plan node type that is diff --git a/src/test/regress/expected/limit.out b/src/test/regress/expected/limit.out index e3bcc680653..f95f0fc3bc6 100644 --- a/src/test/regress/expected/limit.out +++ b/src/test/regress/expected/limit.out @@ -360,6 +360,225 @@ SELECT 10 (10 rows) +-- +-- Test that the LIMIT bound is pushed through the outer side of a left or +-- full join down to a Sort node. +-- +create function explain_limit_bound(query text) returns setof text +language plpgsql as +$$ +declare ln text; +begin + for ln in + execute 'explain (analyze, costs off, timing off, summary off, buffers off) ' + || query + loop + ln := regexp_replace(ln, '\d+kB', 'NNkB', 'g'); + return next regexp_replace(ln, '(Buckets|Batches): \d+', '\1: N', 'g'); + end loop; +end; +$$; +begin; +-- The bound is pushed down the same way for every join method; the others are +-- disabled only to keep the plans stable. +set local enable_hashjoin = off; +set local enable_nestloop = off; +-- the bound is pushed to the Sort: expect a top-N heapsort +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + limit 3 +$$); + explain_limit_bound +--------------------------------------------------------------------- + Limit (actual rows=3.00 loops=1) + -> Merge Left Join (actual rows=3.00 loops=1) + Merge Cond: (onek.tenthous = i.f1) + -> Sort (actual rows=3.00 loops=1) + Sort Key: onek.tenthous + Sort Method: top-N heapsort Memory: NNkB + -> Seq Scan on onek (actual rows=1000.00 loops=1) + -> Sort (actual rows=4.00 loops=1) + Sort Key: i.f1 + Sort Method: quicksort Memory: NNkB + -> Seq Scan on int4_tbl i (actual rows=5.00 loops=1) +(11 rows) + +select o.tenthous, i.f1 +from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 +limit 3; + tenthous | f1 +----------+---- + 0 | 0 + 1 | + 2 | +(3 rows) + +-- a join qual and a qual pushed down below the Sort don't prevent that +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 and i.f1 > 0 + where o.two = 0 + limit 3 +$$); + explain_limit_bound +--------------------------------------------------------------------- + Limit (actual rows=3.00 loops=1) + -> Merge Left Join (actual rows=3.00 loops=1) + Merge Cond: (onek.tenthous = i.f1) + -> Sort (actual rows=3.00 loops=1) + Sort Key: onek.tenthous + Sort Method: top-N heapsort Memory: NNkB + -> Seq Scan on onek (actual rows=500.00 loops=1) + Filter: (two = 0) + Rows Removed by Filter: 500 + -> Sort (actual rows=1.00 loops=1) + Sort Key: i.f1 + Sort Method: quicksort Memory: NNkB + -> Seq Scan on int4_tbl i (actual rows=2.00 loops=1) + Filter: (f1 > 0) + Rows Removed by Filter: 3 +(15 rows) + +select o.tenthous, i.f1 +from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 and i.f1 > 0 +where o.two = 0 +limit 3; + tenthous | f1 +----------+---- + 0 | + 2 | + 4 | +(3 rows) + +-- a qual applied to the join output prevents the top-N heapsort +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + where i.f1 is null or i.f1 > 0 + limit 3 +$$); + explain_limit_bound +--------------------------------------------------------------------- + Limit (actual rows=3.00 loops=1) + -> Merge Left Join (actual rows=3.00 loops=1) + Merge Cond: (onek.tenthous = i.f1) + Filter: ((i.f1 IS NULL) OR (i.f1 > 0)) + Rows Removed by Filter: 1 + -> Sort (actual rows=4.00 loops=1) + Sort Key: onek.tenthous + Sort Method: quicksort Memory: NNkB + -> Seq Scan on onek (actual rows=1000.00 loops=1) + -> Sort (actual rows=4.00 loops=1) + Sort Key: i.f1 + Sort Method: quicksort Memory: NNkB + -> Seq Scan on int4_tbl i (actual rows=5.00 loops=1) +(13 rows) + +-- no pushdown for a left join reduced to an anti join +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + where i.f1 is null + limit 3 +$$); + explain_limit_bound +--------------------------------------------------------------------- + Limit (actual rows=3.00 loops=1) + -> Merge Anti Join (actual rows=3.00 loops=1) + Merge Cond: (onek.tenthous = i.f1) + -> Sort (actual rows=4.00 loops=1) + Sort Key: onek.tenthous + Sort Method: quicksort Memory: NNkB + -> Seq Scan on onek (actual rows=1000.00 loops=1) + -> Sort (actual rows=4.00 loops=1) + Sort Key: i.f1 + Sort Method: quicksort Memory: NNkB + -> Seq Scan on int4_tbl i (actual rows=5.00 loops=1) +(11 rows) + +-- WITH TIES has no fixed bound to push +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + order by o.tenthous fetch first 3 rows with ties +$$); + explain_limit_bound +--------------------------------------------------------------------- + Limit (actual rows=3.00 loops=1) + -> Merge Left Join (actual rows=4.00 loops=1) + Merge Cond: (onek.tenthous = i.f1) + -> Sort (actual rows=4.00 loops=1) + Sort Key: onek.tenthous + Sort Method: quicksort Memory: NNkB + -> Seq Scan on onek (actual rows=1000.00 loops=1) + -> Sort (actual rows=4.00 loops=1) + Sort Key: i.f1 + Sort Method: quicksort Memory: NNkB + -> Seq Scan on int4_tbl i (actual rows=5.00 loops=1) +(11 rows) + +-- rescans with a changing bound must re-sort with the new one +select v.n, s.tenthous, s.f1 +from (values (1), (3), (2)) v(n), + lateral (select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + limit v.n) s +order by v.n, s.tenthous; + n | tenthous | f1 +---+----------+---- + 1 | 0 | 0 + 2 | 0 | 0 + 2 | 1 | + 3 | 0 | 0 + 3 | 1 | + 3 | 2 | +(6 rows) + +set local enable_mergejoin = off; +set local enable_hashjoin = on; +-- a full join keeps every outer row too, so the bound reaches the outer Sort +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + full join int4_tbl i on o.tenthous = i.f1 + limit 3 +$$); + explain_limit_bound +--------------------------------------------------------------------- + Limit (actual rows=3.00 loops=1) + -> Hash Full Join (actual rows=3.00 loops=1) + Hash Cond: (onek.tenthous = i.f1) + -> Sort (actual rows=3.00 loops=1) + Sort Key: onek.tenthous + Sort Method: top-N heapsort Memory: NNkB + -> Seq Scan on onek (actual rows=1000.00 loops=1) + -> Hash (actual rows=5.00 loops=1) + Buckets: N Batches: N Memory Usage: NNkB + -> Seq Scan on int4_tbl i (actual rows=5.00 loops=1) +(10 rows) + +select o.tenthous, i.f1 +from (select * from onek order by tenthous) o + full join int4_tbl i on o.tenthous = i.f1 +limit 3; + tenthous | f1 +----------+---- + 0 | 0 + 1 | + 2 | +(3 rows) + +rollback; +drop function explain_limit_bound(text); -- -- Test behavior of volatile and set-returning functions in conjunction -- with ORDER BY and LIMIT. diff --git a/src/test/regress/sql/limit.sql b/src/test/regress/sql/limit.sql index 603910fe6d1..387b3302986 100644 --- a/src/test/regress/sql/limit.sql +++ b/src/test/regress/sql/limit.sql @@ -92,6 +92,112 @@ SELECT ORDER BY n LIMIT 1 OFFSET s-1) AS y) AS z FROM generate_series(1,10) AS s; +-- +-- Test that the LIMIT bound is pushed through the outer side of a left or +-- full join down to a Sort node. +-- +create function explain_limit_bound(query text) returns setof text +language plpgsql as +$$ +declare ln text; +begin + for ln in + execute 'explain (analyze, costs off, timing off, summary off, buffers off) ' + || query + loop + ln := regexp_replace(ln, '\d+kB', 'NNkB', 'g'); + return next regexp_replace(ln, '(Buckets|Batches): \d+', '\1: N', 'g'); + end loop; +end; +$$; + +begin; + +-- The bound is pushed down the same way for every join method; the others are +-- disabled only to keep the plans stable. +set local enable_hashjoin = off; +set local enable_nestloop = off; + +-- the bound is pushed to the Sort: expect a top-N heapsort +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + limit 3 +$$); +select o.tenthous, i.f1 +from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 +limit 3; + +-- a join qual and a qual pushed down below the Sort don't prevent that +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 and i.f1 > 0 + where o.two = 0 + limit 3 +$$); +select o.tenthous, i.f1 +from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 and i.f1 > 0 +where o.two = 0 +limit 3; + +-- a qual applied to the join output prevents the top-N heapsort +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + where i.f1 is null or i.f1 > 0 + limit 3 +$$); + +-- no pushdown for a left join reduced to an anti join +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + where i.f1 is null + limit 3 +$$); + +-- WITH TIES has no fixed bound to push +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + order by o.tenthous fetch first 3 rows with ties +$$); + +-- rescans with a changing bound must re-sort with the new one +select v.n, s.tenthous, s.f1 +from (values (1), (3), (2)) v(n), + lateral (select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + limit v.n) s +order by v.n, s.tenthous; + +set local enable_mergejoin = off; +set local enable_hashjoin = on; + +-- a full join keeps every outer row too, so the bound reaches the outer Sort +select * from explain_limit_bound($$ + select o.tenthous, i.f1 + from (select * from onek order by tenthous) o + full join int4_tbl i on o.tenthous = i.f1 + limit 3 +$$); +select o.tenthous, i.f1 +from (select * from onek order by tenthous) o + full join int4_tbl i on o.tenthous = i.f1 +limit 3; + +rollback; + +drop function explain_limit_bound(text); + -- -- Test behavior of volatile and set-returning functions in conjunction -- with ORDER BY and LIMIT. -- 2.56.0