From aac253927a8b65d1d08e27a1a2bce4c9320dc57e Mon Sep 17 00:00:00 2001 From: Gleb Kashkin Date: Tue, 22 Sep 2026 17:29:43 +0300 Subject: [PATCH 1/2] Allow FDWs to reparameterize foreign scan paths When building a parameterized Append over a partitioned table, every child must have a path with exactly the requested parameterization. For local scans, get_cheapest_parameterized_child_path() can obtain one by calling reparameterize_path() on an existing path, but foreign paths were not handled there, because core has no way to cost a foreign scan. So if add_path() had discarded a foreign partition's parameterized path (e.g. because the partition is empty and the path was dominated by the unparameterized one), no parameterized Append could be built at all and the planner was forced into a hash or merge join even when a parameterized nestloop would have been far cheaper. Add an optional ReparameterizeForeignPath callback to FdwRoutine, which reparameterize_path() invokes for foreign paths of simple relations, and implement it in postgres_fdw: the callback looks up the ParamPathInfo for the requested outer rels, costs its clauses the same way postgresGetForeignPaths() does, and returns a new unsorted parameterized ForeignPath. --- .../postgres_fdw/expected/postgres_fdw.out | 103 ++++++++++++++++++ contrib/postgres_fdw/postgres_fdw.c | 72 ++++++++++++ contrib/postgres_fdw/sql/postgres_fdw.sql | 49 +++++++++ doc/src/sgml/fdwhandler.sgml | 35 +++++- src/backend/optimizer/util/pathnode.c | 11 ++ src/include/foreign/fdwapi.h | 4 + 6 files changed, 273 insertions(+), 1 deletion(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index e90289e4ab1..e6b17800fe5 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -4581,6 +4581,109 @@ REINDEX TABLE reind_fdw_parent; -- ok REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok DROP TABLE reind_fdw_parent; -- =================================================================== +-- parameterized Append paths over foreign partitions +-- =================================================================== +-- With use_remote_estimate, each foreign partition gets a path parameterized +-- by the join clause. If add_path() discards that path for some partition +-- (here: because the partition is empty, so the parameterized path is +-- dominated by the unparameterized one), the planner must still be able to +-- build a parameterized Append by reparameterizing the surviving path. +CREATE TABLE pa_loc1 (a int); +CREATE TABLE pa_loc2 (a int); +CREATE INDEX ON pa_loc1 (a); +CREATE INDEX ON pa_loc2 (a); +CREATE TABLE pa_parent (a int) PARTITION BY HASH (a); +CREATE FOREIGN TABLE pa_f1 PARTITION OF pa_parent + FOR VALUES WITH (MODULUS 2, REMAINDER 0) + SERVER loopback OPTIONS (table_name 'pa_loc1', use_remote_estimate 'true'); +CREATE FOREIGN TABLE pa_f2 PARTITION OF pa_parent + FOR VALUES WITH (MODULUS 2, REMAINDER 1) + SERVER loopback OPTIONS (table_name 'pa_loc2', use_remote_estimate 'true'); +INSERT INTO pa_loc1 SELECT g FROM generate_series(1, 100000) g + WHERE satisfies_hash_partition('pa_parent'::regclass, 2, 0, g); +INSERT INTO pa_loc2 SELECT g FROM generate_series(1, 100000) g + WHERE satisfies_hash_partition('pa_parent'::regclass, 2, 1, g); +CREATE TABLE pa_outer (a int); +INSERT INTO pa_outer VALUES (1); +ANALYZE pa_loc1; +ANALYZE pa_loc2; +ANALYZE pa_f1; +ANALYZE pa_f2; +ANALYZE pa_outer; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; + QUERY PLAN +---------------------------------------------------------------------------------- + Nested Loop + Output: o.a, p.a + -> Seq Scan on public.pa_outer o + Output: o.a + -> Append + -> Foreign Scan on public.pa_f1 p_1 + Output: p_1.a + Remote SQL: SELECT a FROM public.pa_loc1 WHERE ((a = $1::integer)) + -> Foreign Scan on public.pa_f2 p_2 + Output: p_2.a + Remote SQL: SELECT a FROM public.pa_loc2 WHERE ((a = $1::integer)) +(11 rows) + +-- Empty one partition; its parameterized path is now dominated and dropped. +DELETE FROM pa_f2; +ANALYZE pa_loc2; +ANALYZE pa_f2; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; + QUERY PLAN +---------------------------------------------------------------------------------- + Nested Loop + Output: o.a, p.a + -> Seq Scan on public.pa_outer o + Output: o.a + -> Append + -> Foreign Scan on public.pa_f1 p_1 + Output: p_1.a + Remote SQL: SELECT a FROM public.pa_loc1 WHERE ((a = $1::integer)) + -> Foreign Scan on public.pa_f2 p_2 + Output: p_2.a + Remote SQL: SELECT a FROM public.pa_loc2 WHERE ((a = $1::integer)) +(11 rows) + +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; + a | a +---+--- + 1 | 1 +(1 row) + +-- Once the remote table is known to be empty, the sorted and unsorted plain +-- scans are fuzzily equal in cost, so only the sorted one survives; we must +-- be able to reparameterize that one, too. +VACUUM ANALYZE pa_loc2; +ANALYZE pa_f2; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; + QUERY PLAN +---------------------------------------------------------------------------------- + Nested Loop + Output: o.a, p.a + -> Seq Scan on public.pa_outer o + Output: o.a + -> Append + -> Foreign Scan on public.pa_f1 p_1 + Output: p_1.a + Remote SQL: SELECT a FROM public.pa_loc1 WHERE ((a = $1::integer)) + -> Foreign Scan on public.pa_f2 p_2 + Output: p_2.a + Remote SQL: SELECT a FROM public.pa_loc2 WHERE ((a = $1::integer)) +(11 rows) + +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; + a | a +---+--- + 1 | 1 +(1 row) + +DROP TABLE pa_parent, pa_outer, pa_loc1, pa_loc2; +-- =================================================================== -- conversion error -- =================================================================== ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index 0a589f8db74..929dd129480 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -593,6 +593,9 @@ static void postgresGetForeignJoinPaths(PlannerInfo *root, RelOptInfo *innerrel, JoinType jointype, JoinPathExtraData *extra); +static Path *postgresReparameterizeForeignPath(PlannerInfo *root, + ForeignPath *path, + Relids required_outer); static bool postgresRecheckForeignScan(ForeignScanState *node, TupleTableSlot *slot); static void postgresGetForeignUpperPaths(PlannerInfo *root, @@ -821,6 +824,9 @@ postgres_fdw_handler(PG_FUNCTION_ARGS) /* Support functions for upper relation push-down */ routine->GetForeignUpperPaths = postgresGetForeignUpperPaths; + /* Support functions for path reparameterization */ + routine->ReparameterizeForeignPath = postgresReparameterizeForeignPath; + /* Support functions for asynchronous execution */ routine->IsForeignPathAsyncCapable = postgresIsForeignPathAsyncCapable; routine->ForeignAsyncRequest = postgresForeignAsyncRequest; @@ -1438,6 +1444,72 @@ postgresGetForeignPaths(PlannerInfo *root, } } +/* + * postgresReparameterizeForeignPath + * Build a version of a base-relation foreign scan path that is + * parameterized by required_outer, i.e. that additionally enforces the + * join clauses available from those relations. + */ +static Path * +postgresReparameterizeForeignPath(PlannerInfo *root, ForeignPath *path, + Relids required_outer) +{ + RelOptInfo *baserel = path->path.parent; + PgFdwRelationInfo *fpinfo = (PgFdwRelationInfo *) baserel->fdw_private; + ParamPathInfo *param_info; + double rows; + int width; + int disabled_nodes; + Cost startup_cost; + Cost total_cost; + + Assert(IS_SIMPLE_REL(baserel)); + + /* + * No way to get a good estimate on pushed down clauses, don't build + * parameterized paths. + */ + if (!fpinfo->use_remote_estimate) + return NULL; + + /* + * We don't know how to carry an EPQ subplan along, but base-relation + * paths never have one anyway. + */ + if (path->fdw_outerpath != NULL) + return NULL; + + /* + * Note that we ignore the given path's pathkeys and always produce an + * unsorted path. A parameterized path is only ever used on the inside + * of a NestLoop, where its ordering is of no interest. + */ + + param_info = get_baserel_parampathinfo(root, baserel, required_outer); + if (param_info == NULL) + return NULL; /* shouldn't happen */ + + /* Get a cost estimate from the remote */ + estimate_path_cost_size(root, baserel, + param_info->ppi_clauses, NIL, NULL, + &rows, &width, &disabled_nodes, + &startup_cost, &total_cost); + + param_info->ppi_rows = rows; + + return (Path *) create_foreignscan_path(root, baserel, + NULL, /* default pathtarget */ + rows, + disabled_nodes, + startup_cost, + total_cost, + NIL, /* no pathkeys */ + param_info->ppi_req_outer, + NULL, + NIL, /* no fdw_restrictinfo list */ + NIL); /* no fdw_private list */ +} + /* * postgresGetForeignPlan * Create ForeignScan plan node which implements selected best path diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql index dfc58beb0d2..2ccdb30e2a2 100644 --- a/contrib/postgres_fdw/sql/postgres_fdw.sql +++ b/contrib/postgres_fdw/sql/postgres_fdw.sql @@ -1315,6 +1315,55 @@ REINDEX TABLE reind_fdw_parent; -- ok REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok DROP TABLE reind_fdw_parent; +-- =================================================================== +-- parameterized Append paths over foreign partitions +-- =================================================================== +-- With use_remote_estimate, each foreign partition gets a path parameterized +-- by the join clause. If add_path() discards that path for some partition +-- (here: because the partition is empty, so the parameterized path is +-- dominated by the unparameterized one), the planner must still be able to +-- build a parameterized Append by reparameterizing the surviving path. +CREATE TABLE pa_loc1 (a int); +CREATE TABLE pa_loc2 (a int); +CREATE INDEX ON pa_loc1 (a); +CREATE INDEX ON pa_loc2 (a); +CREATE TABLE pa_parent (a int) PARTITION BY HASH (a); +CREATE FOREIGN TABLE pa_f1 PARTITION OF pa_parent + FOR VALUES WITH (MODULUS 2, REMAINDER 0) + SERVER loopback OPTIONS (table_name 'pa_loc1', use_remote_estimate 'true'); +CREATE FOREIGN TABLE pa_f2 PARTITION OF pa_parent + FOR VALUES WITH (MODULUS 2, REMAINDER 1) + SERVER loopback OPTIONS (table_name 'pa_loc2', use_remote_estimate 'true'); +INSERT INTO pa_loc1 SELECT g FROM generate_series(1, 100000) g + WHERE satisfies_hash_partition('pa_parent'::regclass, 2, 0, g); +INSERT INTO pa_loc2 SELECT g FROM generate_series(1, 100000) g + WHERE satisfies_hash_partition('pa_parent'::regclass, 2, 1, g); +CREATE TABLE pa_outer (a int); +INSERT INTO pa_outer VALUES (1); +ANALYZE pa_loc1; +ANALYZE pa_loc2; +ANALYZE pa_f1; +ANALYZE pa_f2; +ANALYZE pa_outer; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; +-- Empty one partition; its parameterized path is now dominated and dropped. +DELETE FROM pa_f2; +ANALYZE pa_loc2; +ANALYZE pa_f2; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; +-- Once the remote table is known to be empty, the sorted and unsorted plain +-- scans are fuzzily equal in cost, so only the sorted one survives; we must +-- be able to reparameterize that one, too. +VACUUM ANALYZE pa_loc2; +ANALYZE pa_f2; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; +SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a; +DROP TABLE pa_parent, pa_outer, pa_loc1, pa_loc2; + -- =================================================================== -- conversion error -- =================================================================== diff --git a/doc/src/sgml/fdwhandler.sgml b/doc/src/sgml/fdwhandler.sgml index 8685a078c52..a04a113ef2c 100644 --- a/doc/src/sgml/fdwhandler.sgml +++ b/doc/src/sgml/fdwhandler.sgml @@ -1721,6 +1721,36 @@ ReparameterizeForeignPathByChild(PlannerInfo *root, List *fdw_private, adjust_appendrel_attrs or adjust_appendrel_attrs_multilevel as required. + + + +Path * +ReparameterizeForeignPath(PlannerInfo *root, ForeignPath *path, + Relids required_outer); + + This function is called when the planner needs a version of an existing + base-relation ForeignPath that is parameterized + by the relations in required_outer, which is a + superset of the parameterization of path. This + happens, for example, while building a parameterized + Append path over a partitioned table with foreign + partitions, since all children of such a path must have exactly the same + parameterization. The function must return a new + ForeignPath whose param_info + is the value obtained from get_baserel_parampathinfo + for required_outer, and whose row count and cost + estimates account for the additional join clauses found in that value's + ppi_clauses; all of those clauses must be + enforced by the returned path, either remotely or locally. It must + return NULL if no such path can be built, and it must + not pass the returned path to add_path. + + + + This function is optional. If it is not provided, the planner cannot + build a parameterized Append path unless every foreign + child relation already has a path with the required parameterization. + @@ -2014,7 +2044,10 @@ GetForeignServerByName(const char *name, bool missing_ok); to compute that value. In GetForeignPlan, the local_variable portion of the join clause would be added to fdw_exprs, and then at run time the case works the - same as for an ordinary restriction clause. + same as for an ordinary restriction clause. If the FDW provides + ReparameterizeForeignPath, the planner can also ask + it after the fact for a version of an existing path that uses additional + join clauses; see . diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c index 73518c8f870..51f4c38a8a6 100644 --- a/src/backend/optimizer/util/pathnode.c +++ b/src/backend/optimizer/util/pathnode.c @@ -4053,6 +4053,17 @@ reparameterize_path(PlannerInfo *root, Path *path, mpath->binary_mode, mpath->est_calls); } + case T_ForeignScan: + { + ReparameterizeForeignPath_function rfp_func; + + if (!IS_SIMPLE_REL(rel) || rel->fdwroutine == NULL) + break; + rfp_func = rel->fdwroutine->ReparameterizeForeignPath; + if (rfp_func == NULL) + break; + return rfp_func(root, (ForeignPath *) path, required_outer); + } default: break; } diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h index abf59a0d8ad..7a88165919a 100644 --- a/src/include/foreign/fdwapi.h +++ b/src/include/foreign/fdwapi.h @@ -186,6 +186,9 @@ typedef bool (*IsForeignScanParallelSafe_function) (PlannerInfo *root, typedef List *(*ReparameterizeForeignPathByChild_function) (PlannerInfo *root, List *fdw_private, RelOptInfo *child_rel); +typedef Path *(*ReparameterizeForeignPath_function) (PlannerInfo *root, + ForeignPath *path, + Relids required_outer); typedef bool (*IsForeignPathAsyncCapable_function) (ForeignPath *path); @@ -277,6 +280,7 @@ typedef struct FdwRoutine /* Support functions for path reparameterization. */ ReparameterizeForeignPathByChild_function ReparameterizeForeignPathByChild; + ReparameterizeForeignPath_function ReparameterizeForeignPath; /* Support functions for asynchronous execution */ IsForeignPathAsyncCapable_function IsForeignPathAsyncCapable; -- 2.55.0