From 0aac47a0370acc3f492dcb74545333b3f2b2afbf Mon Sep 17 00:00:00 2001 From: Ziming Zhang Date: Fri, 11 Sep 2026 12:16:41 +0800 Subject: [PATCH v1] postgres_fdw: Fix local costing of remote quals after semi-joins When postgres_fdw estimates a foreign join locally, it computes the number of rows surviving the join clauses by applying joinclause_sel to the cross product of the input relations. That is appropriate for ordinary joins, but not for a semi-join. For JOIN_SEMI, joinclause_sel represents the fraction of outer relation rows that have a match in the inner relation. Applying it to the cross product overestimates the number of rows on which remote conditions are evaluated by approximately the number of inner rows. Use the outer relation's row count as the input to joinclause_sel when costing remote conditions after a semi-join. --- .../postgres_fdw/expected/postgres_fdw.out | 31 +++++++++++++++++++ contrib/postgres_fdw/postgres_fdw.c | 12 ++++++- contrib/postgres_fdw/sql/postgres_fdw.sql | 23 ++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index a6295674daf..74aa869e223 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -2225,6 +2225,37 @@ SELECT t1.c1 FROM ft1 t1 WHERE EXISTS (SELECT 1 FROM ft2 t2 WHERE t1.c1 = t2.c1) 110 (10 rows) +-- SEMI join costing should use the outer relation's row count, rather than +-- the size of the cross product, when costing quals applied after the join. +CREATE TABLE semi_tbl1 (a int, b int); +CREATE TABLE semi_tbl2 (a int); +INSERT INTO semi_tbl1 SELECT i, i FROM generate_series(1, 15000) i; +INSERT INTO semi_tbl2 SELECT i FROM generate_series(1, 15000) i; +ANALYZE semi_tbl1; +ANALYZE semi_tbl2; +CREATE FOREIGN TABLE semi_ft1 (a int, b int) SERVER loopback + OPTIONS (table_name 'semi_tbl1'); +CREATE FOREIGN TABLE semi_ft2 (a int) SERVER loopback + OPTIONS (table_name 'semi_tbl2'); +ANALYZE semi_ft1; +ANALYZE semi_ft2; +-- Don't let the generic cross-product join-clause cost obscure this test. +SET cpu_operator_cost TO 0.000001; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT t1.a FROM semi_ft1 t1 +WHERE postgres_fdw_abs(t1.b) > -1 + AND EXISTS (SELECT 1 FROM semi_ft2 t2 WHERE t1.a = t2.a); + QUERY PLAN +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Foreign Scan + Output: t1.a + Relations: (public.semi_ft1 t1) SEMI JOIN (public.semi_ft2 t2) + Remote SQL: SELECT r1.a FROM public.semi_tbl1 r1 WHERE ((public.postgres_fdw_abs(r1.b) > (-1))) AND EXISTS (SELECT NULL FROM public.semi_tbl2 r2 WHERE ((r1.a = r2.a))) +(4 rows) + +RESET cpu_operator_cost; +DROP FOREIGN TABLE semi_ft1, semi_ft2; +DROP TABLE semi_tbl1, semi_tbl2; -- ANTI JOIN, not pushed down EXPLAIN (VERBOSE, COSTS OFF) SELECT t1.c1 FROM ft1 t1 WHERE NOT EXISTS (SELECT 1 FROM ft2 t2 WHERE t1.c1 = t2.c2) ORDER BY t1.c1 OFFSET 100 LIMIT 10; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index c731ee199e2..713bf80a246 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -3640,7 +3640,17 @@ estimate_path_cost_size(PlannerInfo *root, run_cost = fpinfo_i->rel_total_cost - fpinfo_i->rel_startup_cost; run_cost += fpinfo_o->rel_total_cost - fpinfo_o->rel_startup_cost; run_cost += nrows * join_cost.per_tuple; - nrows = clamp_row_est(nrows * fpinfo->joinclause_sel); + + /* + * For a semi-join, joinclause_sel is the fraction of outer + * relation rows that have matches in the inner relation, rather + * than the selectivity of the cross product. + */ + if (fpinfo->jointype == JOIN_SEMI) + nrows = fpinfo_o->rows * fpinfo->joinclause_sel; + else + nrows *= fpinfo->joinclause_sel; + nrows = clamp_row_est(nrows); run_cost += nrows * remote_conds_cost.per_tuple; run_cost += fpinfo->local_conds_cost.per_tuple * retrieved_rows; diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql index eaeb90485e8..fb6dc53d877 100644 --- a/contrib/postgres_fdw/sql/postgres_fdw.sql +++ b/contrib/postgres_fdw/sql/postgres_fdw.sql @@ -707,6 +707,29 @@ SELECT t1.ctid, t1, t2, t1.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER B EXPLAIN (VERBOSE, COSTS OFF) SELECT t1.c1 FROM ft1 t1 WHERE EXISTS (SELECT 1 FROM ft2 t2 WHERE t1.c1 = t2.c1) ORDER BY t1.c1 OFFSET 100 LIMIT 10; SELECT t1.c1 FROM ft1 t1 WHERE EXISTS (SELECT 1 FROM ft2 t2 WHERE t1.c1 = t2.c1) ORDER BY t1.c1 OFFSET 100 LIMIT 10; +-- SEMI join costing should use the outer relation's row count, rather than +-- the size of the cross product, when costing quals applied after the join. +CREATE TABLE semi_tbl1 (a int, b int); +CREATE TABLE semi_tbl2 (a int); +INSERT INTO semi_tbl1 SELECT i, i FROM generate_series(1, 15000) i; +INSERT INTO semi_tbl2 SELECT i FROM generate_series(1, 15000) i; +ANALYZE semi_tbl1; +ANALYZE semi_tbl2; +CREATE FOREIGN TABLE semi_ft1 (a int, b int) SERVER loopback + OPTIONS (table_name 'semi_tbl1'); +CREATE FOREIGN TABLE semi_ft2 (a int) SERVER loopback + OPTIONS (table_name 'semi_tbl2'); +ANALYZE semi_ft1; +ANALYZE semi_ft2; +-- Don't let the generic cross-product join-clause cost obscure this test. +SET cpu_operator_cost TO 0.000001; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT t1.a FROM semi_ft1 t1 +WHERE postgres_fdw_abs(t1.b) > -1 + AND EXISTS (SELECT 1 FROM semi_ft2 t2 WHERE t1.a = t2.a); +RESET cpu_operator_cost; +DROP FOREIGN TABLE semi_ft1, semi_ft2; +DROP TABLE semi_tbl1, semi_tbl2; -- ANTI JOIN, not pushed down EXPLAIN (VERBOSE, COSTS OFF) SELECT t1.c1 FROM ft1 t1 WHERE NOT EXISTS (SELECT 1 FROM ft2 t2 WHERE t1.c1 = t2.c2) ORDER BY t1.c1 OFFSET 100 LIMIT 10; -- 2.34.1