From 4a99d356b227819e6615a4659986a3a9462e8a5d Mon Sep 17 00:00:00 2001 From: "Andrei V. Lepikhov" Date: Thu, 2 Apr 2026 15:35:16 +0200 Subject: [PATCH v3] Propagate LIMIT bound through the outer side of left and full joins. ExecSetTupleBound() passes the number of tuples needed down to a Sort node through upper compatible nodes, enabling a bounded top-N sort. So far it stopped at any join. A LEFT/FULL NestLoop or MergeJoin emits at least one tuple for each outer tuple, joined or null-extended, before it fetches the next one. Such a join therefore never asks its outer side for more tuples than it has returned, and the bound can be passed down to the outer side unchanged. That holds only if the join has no qual on its output, which could filter the emitted tuples. HashJoin is deliberately skipped. A multi-batch hash join writes outer tuples belonging to later batches to temporary files without emitting anything, and the number of batches may grow at run time. It can thus fetch more outer tuples than it returns. --- src/backend/executor/execProcnode.c | 14 ++ src/test/regress/expected/limit.out | 293 ++++++++++++++++++++++++++++ src/test/regress/sql/limit.sql | 131 +++++++++++++ 3 files changed, 438 insertions(+) diff --git a/src/backend/executor/execProcnode.c b/src/backend/executor/execProcnode.c index 7c4c66e323f..20dfe5a131b 100644 --- a/src/backend/executor/execProcnode.c +++ b/src/backend/executor/execProcnode.c @@ -958,6 +958,20 @@ ExecSetTupleBound(int64 tuples_needed, PlanState *child_node) ExecSetTupleBound(tuples_needed, outerPlanState(child_node)); } + else if (IsA(child_node, NestLoopState) || IsA(child_node, MergeJoinState)) + { + /* + * A left or full join emits at least one tuple for each outer tuple + * before fetching the next one, so unless a qual filters its output it + * never needs more outer tuples than it returns. HashJoin doesn't + * qualify: a multi-batch join pulls more outer tuples into batches. + */ + 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..b0e7bbddf38 100644 --- a/src/test/regress/expected/limit.out +++ b/src/test/regress/expected/limit.out @@ -360,6 +360,299 @@ 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 + return next regexp_replace(ln, '\d+kB', 'NNkB', 'g'); + end loop; +end; +$$; +begin; +-- Force merge join to keep 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 + order by o.tenthous + 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 +order by o.tenthous +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 + order by o.tenthous + 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 +order by o.tenthous +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 + order by o.tenthous + 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) + +-- 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) + -> Merge Full Join (actual rows=3.00 loops=1) + Merge Cond: (onek.tenthous = i.f1) + -> Sort (actual rows=1.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=3.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 + full join int4_tbl i on o.tenthous = i.f1 +limit 3; + tenthous | f1 +----------+------------- + | -2147483647 + | -123456 + 0 | 0 +(3 rows) + +-- the bound passes through a chain of left joins +select * from explain_limit_bound($$ + select o.tenthous, i.f1, j.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + left join int4_tbl j on o.tenthous = j.f1 + order by o.tenthous + 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 = j.f1) + -> 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) + -> Sort (actual rows=4.00 loops=1) + Sort Key: j.f1 + Sort Method: quicksort Memory: NNkB + -> Seq Scan on int4_tbl j (actual rows=5.00 loops=1) +(17 rows) + +select o.tenthous, i.f1, j.f1 +from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + left join int4_tbl j on o.tenthous = j.f1 +order by o.tenthous +limit 3; + tenthous | f1 | f1 +----------+----+---- + 0 | 0 | 0 + 1 | | + 2 | | +(3 rows) + +-- but stops at an inner join in the chain +select * from explain_limit_bound($$ + select o.tenthous, i.f1, j.f1 + from (select * from onek order by tenthous) o + join int4_tbl i on o.tenthous = i.f1 + left join int4_tbl j on o.tenthous = j.f1 + limit 3 +$$); + explain_limit_bound +--------------------------------------------------------------------------- + Limit (actual rows=1.00 loops=1) + -> Merge Left Join (actual rows=1.00 loops=1) + Merge Cond: (onek.tenthous = j.f1) + -> Merge Join (actual rows=1.00 loops=1) + Merge Cond: (onek.tenthous = i.f1) + -> Sort (actual rows=1000.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) + -> Sort (actual rows=4.00 loops=1) + Sort Key: j.f1 + Sort Method: quicksort Memory: NNkB + -> Seq Scan on int4_tbl j (actual rows=5.00 loops=1) +(17 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..558c9904944 100644 --- a/src/test/regress/sql/limit.sql +++ b/src/test/regress/sql/limit.sql @@ -92,6 +92,137 @@ 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 + return next regexp_replace(ln, '\d+kB', 'NNkB', 'g'); + end loop; +end; +$$; + +begin; + +-- Force merge join to keep 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 + order by o.tenthous + 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 +order by o.tenthous +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 + order by o.tenthous + 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 +order by o.tenthous +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 + order by o.tenthous + limit v.n) s +order by v.n, s.tenthous; + +-- 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; + +-- the bound passes through a chain of left joins +select * from explain_limit_bound($$ + select o.tenthous, i.f1, j.f1 + from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + left join int4_tbl j on o.tenthous = j.f1 + order by o.tenthous + limit 3 +$$); +select o.tenthous, i.f1, j.f1 +from (select * from onek order by tenthous) o + left join int4_tbl i on o.tenthous = i.f1 + left join int4_tbl j on o.tenthous = j.f1 +order by o.tenthous +limit 3; + +-- but stops at an inner join in the chain +select * from explain_limit_bound($$ + select o.tenthous, i.f1, j.f1 + from (select * from onek order by tenthous) o + join int4_tbl i on o.tenthous = i.f1 + left join int4_tbl j on o.tenthous = j.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