From cd72b15d4a86eb165e3cb8cb381a6ae55778a74a Mon Sep 17 00:00:00 2001 From: Dinesh Salve Date: Thu, 20 Aug 2026 20:03:28 +0530 Subject: [PATCH v8] postgres_fdw: show remote EXPLAIN plans via REMOTE_PLANS option Add a REMOTE_PLANS option to EXPLAIN that, for every foreign scan or foreign modification, runs EXPLAIN on the remote server for the corresponding remote query and prints the result in a "Remote Plans" section keyed by the local plan node id. The remote query that postgres_fdw sends can contain $n parameter placeholders, which a plain remote EXPLAIN cannot plan and fails with "there is no parameter $1". For statements with placeholders, REMOTE_PLANS adds GENERIC_PLAN to the remote EXPLAIN so that the statement can be planned without bound values. Statements without placeholders do not need GENERIC_PLAN and are sent without it. For foreign scans and direct modifications, the ForeignScan node's fdw_exprs list identifies whether the deparsed query has parameters. Ordinary UPDATE and DELETE statements always have a parameter for ctid. For an ordinary INSERT, parameters are present only when at least one target attribute is not generated, since generated attributes are deparsed as DEFAULT. GENERIC_PLAN is only available from PostgreSQL 16, so REMOTE_PLANS requires a server of that version or later only for statements containing placeholders. REMOTE_PLANS cannot be combined with ANALYZE. All user-specified EXPLAIN options (other than REMOTE_PLANS and GENERIC_PLAN) are forwarded to the remote server as-is. If the remote server does not recognize a forwarded option, it reports an error. The option is registered, and the EXPLAIN hooks it needs are installed, by postgres_fdw_explain_init(), which _PG_init calls. The work is split across three hooks: explain_validate_options_hook rejects REMOTE_PLANS together with ANALYZE and records the user's option list for forwarding, explain_per_node_hook labels each node with its plan node id, and explain_per_plan_hook prints the remote plans collected while the plan was walked. Each of these chains to the hook it replaced, so other modules using the same hooks keep working regardless of the order in which the modules are loaded. Since the library is loaded on demand, the option is only recognized once postgres_fdw has been loaded into the session, whether by an earlier query against a foreign table, by LOAD, or by a preload GUC. Note that CREATE EXTENSION alone does not load it into the current session. Tests for the option itself live alongside the existing tests for the plans they report on, in postgres_fdw.sql. The non-text output formats are covered separately in remote_plans_formats.sql, so that the volume of JSON/XML/YAML output does not swamp the main test file. --- contrib/postgres_fdw/Makefile | 2 +- .../postgres_fdw/expected/postgres_fdw.out | 219 +++++++++ .../expected/remote_plans_formats.out | 114 +++++ contrib/postgres_fdw/meson.build | 1 + contrib/postgres_fdw/option.c | 3 + contrib/postgres_fdw/postgres_fdw.c | 440 +++++++++++++++++- contrib/postgres_fdw/postgres_fdw.h | 1 + contrib/postgres_fdw/sql/postgres_fdw.sql | 56 +++ .../postgres_fdw/sql/remote_plans_formats.sql | 19 + doc/src/sgml/postgres-fdw.sgml | 101 ++++ src/tools/pgindent/typedefs.list | 2 + 11 files changed, 945 insertions(+), 13 deletions(-) create mode 100644 contrib/postgres_fdw/expected/remote_plans_formats.out create mode 100644 contrib/postgres_fdw/sql/remote_plans_formats.sql diff --git a/contrib/postgres_fdw/Makefile b/contrib/postgres_fdw/Makefile index b8c78b58804..671f82cdb42 100644 --- a/contrib/postgres_fdw/Makefile +++ b/contrib/postgres_fdw/Makefile @@ -16,7 +16,7 @@ SHLIB_LINK_INTERNAL = $(libpq) EXTENSION = postgres_fdw DATA = postgres_fdw--1.0.sql postgres_fdw--1.0--1.1.sql postgres_fdw--1.1--1.2.sql postgres_fdw--1.2--1.3.sql -REGRESS = postgres_fdw query_cancel +REGRESS = postgres_fdw remote_plans_formats query_cancel ISOLATION = eval_plan_qual ISOLATION_OPTS = --load-extension=postgres_fdw TAP_TESTS = 1 diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index a6295674daf..4399e18b4b4 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -471,6 +471,36 @@ SELECT 'fixed', NULL FROM ft1 t1 WHERE c1 = 1; fixed | (1 row) +-- with WHERE clause and REMOTE_PLANS +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) SELECT * FROM ft1 t1 WHERE t1.c1 = 101; + QUERY PLAN +----------------------------------------------------------------------------------------------- + Foreign Scan on public.ft1 t1 + Output: c1, c2, c3, c4, c5, c6, c7, c8 + Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" = 101)) + Plan Node ID: 0 + Remote Plans: + ------------- + Plan Node ID 0: + Index Scan using t1_pkey on "S 1"."T 1" + Output: "C 1", c2, c3, c4, c5, c6, c7, c8 + Index Cond: ("T 1"."C 1" = 101) +(10 rows) + +-- an error raised by the remote EXPLAIN is reported to the client; without +-- REMOTE_PLANS this plan never contacts the remote server at all +CREATE FOREIGN TABLE remote_plans_bad (c1 int) + SERVER loopback OPTIONS (schema_name 'S 1', table_name 'no_such_table'); +EXPLAIN (COSTS OFF) SELECT * FROM remote_plans_bad; + QUERY PLAN +---------------------------------- + Foreign Scan on remote_plans_bad +(1 row) + +EXPLAIN (REMOTE_PLANS, COSTS OFF) SELECT * FROM remote_plans_bad; +ERROR: relation "S 1.no_such_table" does not exist +CONTEXT: remote SQL command: EXPLAIN (costs off) SELECT c1 FROM "S 1".no_such_table +DROP FOREIGN TABLE remote_plans_bad; -- Test forcing the remote server to produce sorted data for a merge join. SET enable_hashjoin TO false; SET enable_nestloop TO false; @@ -646,6 +676,57 @@ SELECT t1."C 1", t2.c1, t3.c1 FROM "S 1"."T 1" t1 full join ft1 t2 full join ft2 110 | 110 | 110 (10 rows) +-- Join push-down test +-- Ensure join conditions are pushed down to the foreign server +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) + SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) WHERE t1.c2 = 10; + QUERY PLAN +----------------------------------------------------------------------------------------------------------------------------------------- + Foreign Scan + Output: t1.c1, t2.c1 + Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2) + Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")) AND ((r1.c2 = 10)))) + Plan Node ID: 0 + Remote Plans: + ------------- + Plan Node ID 0: + Seq Scan on "S 1"."T 1" r2 + Output: r2."C 1", r2."C 1" + Filter: (r2.c2 = 10) +(11 rows) + +-- Tables on multiple foreign connections: ft5 lives on server loopback and +-- ft6 on server loopback2, so the join cannot be pushed down as a single +-- foreign join. Each side is scanned over its own connection, so REMOTE_PLANS +-- must collect and print one remote plan per connection. +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) + SELECT t1.c1, t2.c1 FROM ft5 t1 JOIN ft6 t2 ON (t1.c1 = t2.c1) WHERE t1.c1 = 10; + QUERY PLAN +------------------------------------------------------------------ + Nested Loop + Disabled: true + Output: t1.c1, t2.c1 + Plan Node ID: 0 + -> Foreign Scan on public.ft5 t1 + Output: t1.c1, t1.c2, t1.c3 + Remote SQL: SELECT c1 FROM "S 1"."T 4" WHERE ((c1 = 10)) + Plan Node ID: 1 + -> Foreign Scan on public.ft6 t2 + Output: t2.c1, t2.c2, t2.c3 + Remote SQL: SELECT c1 FROM "S 1"."T 4" WHERE ((c1 = 10)) + Plan Node ID: 2 + Remote Plans: + ------------- + Plan Node ID 1: + Seq Scan on "S 1"."T 4" + Output: c1 + Filter: ("T 4".c1 = 10) + Plan Node ID 2: + Seq Scan on "S 1"."T 4" + Output: c1 + Filter: ("T 4".c1 = 10) +(22 rows) + RESET enable_hashjoin; RESET enable_nestloop; -- Test executing assertion in estimate_path_cost_size() that makes sure that @@ -790,6 +871,32 @@ SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2; 47 | 7 | 00047 | Tue Feb 17 00:00:00 1970 PST | Tue Feb 17 00:00:00 1970 | 7 | 7 | foo | 7 | 7 | 00007 | Thu Jan 08 00:00:00 1970 PST | Thu Jan 08 00:00:00 1970 | 7 | 7 | foo (1 row) +-- REMOTE_PLANS over a parameterized foreign scan: the deparsed remote SQL +-- carries a $1 placeholder, which only plans on the remote side because +-- REMOTE_PLANS forces GENERIC_PLAN. +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) + SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2; + QUERY PLAN +------------------------------------------------------------------------------------------------------------- + Nested Loop + Output: a."C 1", a.c2, a.c3, a.c4, a.c5, a.c6, a.c7, a.c8, b.c1, b.c2, b.c3, b.c4, b.c5, b.c6, b.c7, b.c8 + Plan Node ID: 0 + -> Index Scan using t1_pkey on "S 1"."T 1" a + Output: a."C 1", a.c2, a.c3, a.c4, a.c5, a.c6, a.c7, a.c8 + Index Cond: (a."C 1" = 47) + Plan Node ID: 1 + -> Foreign Scan on public.ft2 b + Output: b.c1, b.c2, b.c3, b.c4, b.c5, b.c6, b.c7, b.c8 + Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" = $1::integer)) + Plan Node ID: 2 + Remote Plans: + ------------- + Plan Node ID 2: + Index Scan using t1_pkey on "S 1"."T 1" + Output: "C 1", c2, c3, c4, c5, c6, c7, c8 + Index Cond: ("T 1"."C 1" = $1) +(17 rows) + -- check both safe and unsafe join conditions EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ft2 a, ft2 b @@ -5816,6 +5923,45 @@ SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE Remote SQL: SELECT r5."C 1", r6.c1 FROM ("S 1"."T 1" r5 INNER JOIN "S 1"."T 3" r6 ON (((r5."C 1" = r6.c1)))) ORDER BY r5."C 1" ASC NULLS LAST (13 rows) +-- EXPLAIN with REMOTE_PLANS +-- REMOTE_PLANS cannot be combined with ANALYZE +EXPLAIN (REMOTE_PLANS, COSTS OFF, ANALYZE) +SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE + ft1.c1 IN ( + SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1) + ORDER BY ft1.c1 LIMIT 5; +ERROR: EXPLAIN options REMOTE_PLANS and ANALYZE cannot be used together +EXPLAIN (REMOTE_PLANS, COSTS OFF) +SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE + ft1.c1 IN ( + SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1) + ORDER BY ft1.c1 LIMIT 5; + QUERY PLAN +------------------------------------------------------- + Limit + Plan Node ID: 0 + -> Merge Semi Join + Merge Cond: (ft1.c1 = ft2_1.c1) + Plan Node ID: 1 + -> Foreign Scan + Relations: (ft1) INNER JOIN (ft2) + Plan Node ID: 2 + -> Foreign Scan + Relations: (ft2 ft2_1) INNER JOIN (ft4) + Plan Node ID: 3 + Remote Plans: + ------------- + Plan Node ID 2: + Index Only Scan using t1_pkey on "T 1" r2 + Plan Node ID 3: + Merge Join + Merge Cond: (r5."C 1" = r6.c1) + -> Index Only Scan using t1_pkey on "T 1" r5 + -> Sort + Sort Key: r6.c1 + -> Seq Scan on "T 3" r6 +(22 rows) + -- =================================================================== -- test writable foreign table stuff -- =================================================================== @@ -7090,6 +7236,25 @@ SELECT c1, c2, c3, c4 FROM fpo_part_local ORDER BY c4; (3 rows) DROP TABLE fpo_part_parent; +-- test write on foreign tables with REMOTE_PLANS +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) +UPDATE ft2 SET c2 = c2 + 300 WHERE c1 % 10 = 3; + QUERY PLAN +--------------------------------------------------------------------------------------- + Update on public.ft2 + Plan Node ID: 0 + -> Foreign Update on public.ft2 + Remote SQL: UPDATE "S 1"."T 1" SET c2 = (c2 + 300) WHERE ((("C 1" % 10) = 3)) + Plan Node ID: 1 + Remote Plans: + ------------- + Plan Node ID 1: + Update on "S 1"."T 1" + -> Seq Scan on "S 1"."T 1" + Output: (c2 + 300), ctid + Filter: (("T 1"."C 1" % 10) = 3) +(12 rows) + -- Test UPDATE/DELETE with RETURNING on a three-table join INSERT INTO ft2 (c1,c2,c3) SELECT id, id - 1200, to_char(id, 'FM00000') FROM generate_series(1201, 1300) id; @@ -13188,6 +13353,58 @@ SELECT * FROM insert_tbl ORDER BY a; 2505 | 505 | bar (2 rows) +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) +INSERT INTO insert_tbl (SELECT * FROM local_tbl UNION ALL SELECT * FROM remote_tbl); + QUERY PLAN +------------------------------------------------------------------------- + Insert on public.insert_tbl + Remote SQL: INSERT INTO public.base_tbl4(a, b, c) VALUES ($1, $2, $3) + Batch Size: 1 + Plan Node ID: 0 + -> Append + Plan Node ID: 1 + -> Seq Scan on public.local_tbl + Output: local_tbl.a, local_tbl.b, local_tbl.c + Plan Node ID: 2 + -> Async Foreign Scan on public.remote_tbl + Output: remote_tbl.a, remote_tbl.b, remote_tbl.c + Remote SQL: SELECT a, b, c FROM public.base_tbl3 + Plan Node ID: 3 + Remote Plans: + ------------- + Plan Node ID 0: + Insert on public.base_tbl4 + -> Result + Output: $1, $2, $3 + Plan Node ID 3: + Seq Scan on public.base_tbl3 + Output: a, b, c +(22 rows) + +-- An INSERT whose target list contains only generated columns has no +-- placeholders, despite having a nonempty targetAttrs list. +CREATE TABLE generated_tbl (a int GENERATED ALWAYS AS (1) STORED); +CREATE FOREIGN TABLE generated_ft (a int GENERATED ALWAYS AS (1) STORED) + SERVER loopback OPTIONS (table_name 'generated_tbl'); +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) +INSERT INTO generated_ft DEFAULT VALUES; + QUERY PLAN +-------------------------------------------------------------------- + Insert on public.generated_ft + Remote SQL: INSERT INTO public.generated_tbl(a) VALUES (DEFAULT) + Batch Size: 1 + Plan Node ID: 0 + -> Result + Output: NULL::integer + Plan Node ID: 1 + Remote Plans: + ------------- + Plan Node ID 0: + Insert on public.generated_tbl + -> Result + Output: NULL::integer +(13 rows) + -- Check with direct modify EXPLAIN (VERBOSE, COSTS OFF) WITH t AS (UPDATE remote_tbl SET c = c || c RETURNING *) @@ -13233,8 +13450,10 @@ DELETE FROM join_tbl; DROP TABLE local_tbl; DROP FOREIGN TABLE remote_tbl; DROP FOREIGN TABLE insert_tbl; +DROP FOREIGN TABLE generated_ft; DROP TABLE base_tbl3; DROP TABLE base_tbl4; +DROP TABLE generated_tbl; RESET enable_mergejoin; RESET enable_hashjoin; -- Test that UPDATE/DELETE with inherited target works with async_capable enabled diff --git a/contrib/postgres_fdw/expected/remote_plans_formats.out b/contrib/postgres_fdw/expected/remote_plans_formats.out new file mode 100644 index 00000000000..37b64b44f77 --- /dev/null +++ b/contrib/postgres_fdw/expected/remote_plans_formats.out @@ -0,0 +1,114 @@ +-- =================================================================== +-- test EXPLAIN (REMOTE_PLANS) in the non-text output formats +-- =================================================================== +-- The behaviour of REMOTE_PLANS itself is tested in postgres_fdw.sql, next +-- to the tests for the plans it reports on. This file only covers how the +-- collected remote plans are embedded in each output format. +-- This runs after postgres_fdw.sql in the same database and reuses the +-- server, user mapping and foreign tables created there. +LOAD 'postgres_fdw'; +EXPLAIN (REMOTE_PLANS, FORMAT JSON, COSTS OFF) +SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101; + QUERY PLAN +----------------------------------------------- + [ + + { + + "Plan": { + + "Node Type": "Foreign Scan", + + "Operation": "Select", + + "Parallel Aware": false, + + "Async Capable": false, + + "Relation Name": "ft1", + + "Alias": "t1", + + "Disabled": false, + + "Plan Node ID": 0 + + }, + + "Remote Plans": { + + "Plan Node ID 0": [ + + [ + + { + + "Plan": { + + "Node Type": "Index Only Scan",+ + "Parallel Aware": false, + + "Async Capable": false, + + "Scan Direction": "Forward", + + "Index Name": "t1_pkey", + + "Relation Name": "T 1", + + "Alias": "T 1", + + "Disabled": false, + + "Index Cond": "(\"C 1\" = 101)"+ + } + + } + + ] + + ] + + } + + } + + ] +(1 row) + +EXPLAIN (REMOTE_PLANS, FORMAT XML, COSTS OFF) +SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101; + QUERY PLAN +------------------------------------------------------------------ + + + + + + + Foreign Scan + + Select + + false + + false + + ft1 + + t1 + + false + + 0 + + + + + + + + + + + + + + Index Only Scan + + false + + false + + Forward + + t1_pkey + + T 1 + + T 1 + + false + + ("C 1" = 101) + + + + + + + + + + + + + + +(1 row) + +EXPLAIN (REMOTE_PLANS, FORMAT YAML, COSTS OFF) +SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101; + QUERY PLAN +----------------------------------------- + - Plan: + + Node Type: "Foreign Scan" + + Operation: "Select" + + Parallel Aware: false + + Async Capable: false + + Relation Name: "ft1" + + Alias: "t1" + + Disabled: false + + Plan Node ID: 0 + + Remote Plans: + + Plan Node ID 0: + + - Plan: + + Node Type: "Index Only Scan" + + Parallel Aware: false + + Async Capable: false + + Scan Direction: "Forward" + + Index Name: "t1_pkey" + + Relation Name: "T 1" + + Alias: "T 1" + + Disabled: false + + Index Cond: "(\"C 1\" = 101)" +(1 row) + diff --git a/contrib/postgres_fdw/meson.build b/contrib/postgres_fdw/meson.build index 3e2ed06b766..6a9bb651d02 100644 --- a/contrib/postgres_fdw/meson.build +++ b/contrib/postgres_fdw/meson.build @@ -38,6 +38,7 @@ tests += { 'regress': { 'sql': [ 'postgres_fdw', + 'remote_plans_formats', 'query_cancel', ], 'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'], diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c index 5b539c4eeef..67ac0825947 100644 --- a/contrib/postgres_fdw/option.c +++ b/contrib/postgres_fdw/option.c @@ -592,4 +592,7 @@ _PG_init(void) NULL); MarkGUCPrefixReserved("postgres_fdw"); + + /* Register EXPLAIN (REMOTE_PLANS) and install the hooks it needs. */ + postgres_fdw_explain_init(); } diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index adfdb91cc20..2a42924962d 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -19,6 +19,7 @@ #include "access/table.h" #include "catalog/pg_opfamily.h" #include "commands/defrem.h" +#include "commands/explain.h" #include "commands/explain_format.h" #include "commands/explain_state.h" #include "commands/vacuum.h" @@ -165,6 +166,44 @@ enum FdwDirectModifyPrivateIndex FdwDirectModifyPrivateMinRTIndex, }; +/* + * One remote plan collected while walking the local plan, identified by the + * plan node id of the local node that produced the remote query. + */ +typedef struct PgFdwExplainRemotePlans +{ + int plan_node_id; + StringInfoData explain_plan; +} PgFdwExplainRemotePlans; + +/* + * Our private state within an ExplainState, created on demand while the + * EXPLAIN options are parsed. + */ +typedef struct PgFdwExplainState +{ + List *all_remote_plans; + + /* EXPLAIN options */ + bool remote_plans; + List *options; /* raw user DefElem list, for forwarding */ +} PgFdwExplainState; + +/* + * Our slot in an ExplainState's extension state array, assigned once by + * postgres_fdw_explain_init(). + */ +static int postgres_fdw_extension_id; + +/* + * Previously installed EXPLAIN hooks. Every hook function we install must + * call the previous one, so that other modules using the same hooks keep + * working no matter what order the modules happen to be loaded in. + */ +static explain_per_node_hook_type prev_explain_per_node_hook = NULL; +static explain_per_plan_hook_type prev_explain_per_plan_hook = NULL; +static explain_validate_options_hook_type prev_explain_validate_options_hook = NULL; + /* * Execution state of a foreign scan using postgres_fdw. */ @@ -3067,6 +3106,121 @@ postgresEndDirectModify(ForeignScanState *node) /* MemoryContext will be deleted automatically. */ } +static void +postgresExplainStatement(int plan_node_id, + PgFdwExplainState *pgfdw_explain_state, + PGconn *conn, + char *sql, + bool has_params) +{ + PGresult *volatile res = NULL; + StringInfoData explain_sql; + int remote_version = PQserverVersion(conn); + bool first = true; + ListCell *lc; + + /* + * The deparsed SQL may contain $n placeholders, which a plain remote + * EXPLAIN cannot plan: it fails with "there is no parameter $1". We + * therefore force GENERIC_PLAN when placeholders are present and that + * option is only available from PG 16. + */ + if (has_params && remote_version < 160000) + ereport(ERROR, + errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("EXPLAIN option REMOTE_PLANS requires a remote server version of 16 or later for parameterized queries"), + errdetail("The remote server version is %d.", remote_version)); + + /* Forward user-specified options as-is. */ + initStringInfo(&explain_sql); + appendStringInfoString(&explain_sql, "EXPLAIN"); + + if (has_params) + { + appendStringInfoString(&explain_sql, " (GENERIC_PLAN TRUE"); + first = false; + } + + foreach(lc, pgfdw_explain_state->options) + { + DefElem *opt = (DefElem *) lfirst(lc); + + if (strcmp(opt->defname, "remote_plans") == 0 || + strcmp(opt->defname, "generic_plan") == 0) + continue; + + appendStringInfoString(&explain_sql, first ? " (" : ", "); + first = false; + + if (opt->arg == NULL) + appendStringInfoString(&explain_sql, opt->defname); + else + appendStringInfo(&explain_sql, "%s %s", opt->defname, + defGetString(opt)); + } + + if (!first) + appendStringInfoChar(&explain_sql, ')'); + appendStringInfo(&explain_sql, " %s", sql); + + PG_TRY(); + { + int numrows, + i; + PgFdwExplainRemotePlans *explain; + + /* Run the query and collect the remote plan */ + res = pgfdw_exec_query(conn, explain_sql.data, NULL); + if (PQresultStatus(res) != PGRES_TUPLES_OK) + pgfdw_report_error(res, conn, explain_sql.data); + + explain = (PgFdwExplainRemotePlans *) palloc(sizeof(PgFdwExplainRemotePlans)); + initStringInfo(&explain->explain_plan); + + numrows = PQntuples(res); + + for (i = 0; i < numrows; i++) + appendStringInfo(&explain->explain_plan, "%s\n", PQgetvalue(res, i, 0)); + + if (explain->explain_plan.len > 0 && explain->explain_plan.data[explain->explain_plan.len - 1] == '\n') + explain->explain_plan.data[--explain->explain_plan.len] = '\0'; + + explain->plan_node_id = plan_node_id; + pgfdw_explain_state->all_remote_plans = lappend(pgfdw_explain_state->all_remote_plans, explain); + } + PG_FINALLY(); + { + if (res) + PQclear(res); + } + PG_END_TRY(); + + pfree(explain_sql.data); +} + +/* + * explain_remote_query + * Helper function to get connection and execute remote EXPLAIN + */ +static void +explain_remote_query(int plan_node_id, + PgFdwExplainState *pgfdw_explain_state, + Oid foreign_table_oid, char *sql, + bool has_params) +{ + UserMapping *user; + PGconn *conn; + ForeignTable *table; + + table = GetForeignTable(foreign_table_oid); + user = GetUserMapping(GetUserId(), table->serverid); + conn = GetConnection(user, false, NULL); + + postgresExplainStatement(plan_node_id, pgfdw_explain_state, conn, sql, + has_params); + ReleaseConnection(conn); +} + /* * postgresExplainForeignScan * Produce extra output for EXPLAIN of a ForeignScan on a foreign table @@ -3076,6 +3230,12 @@ postgresExplainForeignScan(ForeignScanState *node, ExplainState *es) { ForeignScan *plan = castNode(ForeignScan, node->ss.ps.plan); List *fdw_private = plan->fdw_private; + PgFdwExplainState *pgfdw_explain_state; + char *sql; + Oid foreign_table_oid = InvalidOid; + + if (node->ss.ss_currentRelation) + foreign_table_oid = RelationGetRelid(node->ss.ss_currentRelation); /* * Identify foreign scans that are really joins or upper relations. The @@ -3196,6 +3356,12 @@ postgresExplainForeignScan(ForeignScanState *node, ExplainState *es) { Assert(rte->rtekind == RTE_RELATION); + /* + * Save first table OID for getting server connection. + */ + if (!OidIsValid(foreign_table_oid)) + foreign_table_oid = rte->relid; + /* * This logic should agree with explain.c's * ExplainTargetRel @@ -3228,16 +3394,25 @@ postgresExplainForeignScan(ForeignScanState *node, ExplainState *es) ExplainPropertyText("Relations", relations.data, es); } + sql = strVal(list_nth(fdw_private, FdwScanPrivateSelectSql)); + /* * Add remote query, when VERBOSE option is specified. */ if (es->verbose) - { - char *sql; - - sql = strVal(list_nth(fdw_private, FdwScanPrivateSelectSql)); ExplainPropertyText("Remote SQL", sql, es); - } + + pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id); + + /* If we don't have a foreign table oid by now, something went wrong */ + Assert(foreign_table_oid); + + if (pgfdw_explain_state && pgfdw_explain_state->remote_plans) + explain_remote_query(node->ss.ps.plan->plan_node_id, + pgfdw_explain_state, + foreign_table_oid, + sql, + plan->fdw_exprs != NIL); } /* @@ -3251,11 +3426,13 @@ postgresExplainForeignModify(ModifyTableState *mtstate, int subplan_index, ExplainState *es) { + char *sql = strVal(list_nth(fdw_private, + FdwModifyPrivateUpdateSql)); + PgFdwExplainState *pgfdw_explain_state; + bool has_params; + if (es->verbose) { - char *sql = strVal(list_nth(fdw_private, - FdwModifyPrivateUpdateSql)); - ExplainPropertyText("Remote SQL", sql, es); /* @@ -3265,6 +3442,57 @@ postgresExplainForeignModify(ModifyTableState *mtstate, if (rinfo->ri_BatchSize > 0) ExplainPropertyInteger("Batch Size", NULL, rinfo->ri_BatchSize, es); } + + pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id); + if (pgfdw_explain_state && pgfdw_explain_state->remote_plans) + { + switch (mtstate->operation) + { + case CMD_UPDATE: + case CMD_DELETE: + /* The row identifier is always sent as $1. */ + has_params = true; + break; + case CMD_INSERT: + { + TupleDesc tupdesc = RelationGetDescr(rinfo->ri_RelationDesc); + List *target_attrs = (List *) list_nth(fdw_private, + FdwModifyPrivateTargetAttnums); + ListCell *lc; + + /* + * A nonempty target_attrs list does not necessarily imply + * parameters. Generated columns are deparsed as DEFAULT. + * The INSERT has parameters only if at least one target + * attribute is not generated. + */ + has_params = false; + foreach(lc, target_attrs) + { + AttrNumber attnum = lfirst_int(lc); + CompactAttribute *attr = + TupleDescCompactAttr(tupdesc, attnum - 1); + + /* '\0' identifies an ordinary, non-generated column. */ + if (attr->attgenerated == '\0') + { + has_params = true; + break; + } + } + break; + } + default: + elog(ERROR, "unexpected operation: %d", + (int) mtstate->operation); + } + + explain_remote_query(mtstate->ps.plan->plan_node_id, + pgfdw_explain_state, + rinfo->ri_RelationDesc->rd_rel->oid, + sql, + has_params); + } } /* @@ -3275,15 +3503,25 @@ postgresExplainForeignModify(ModifyTableState *mtstate, static void postgresExplainDirectModify(ForeignScanState *node, ExplainState *es) { + ForeignScan *plan = castNode(ForeignScan, node->ss.ps.plan); List *fdw_private; char *sql; + PgFdwExplainState *pgfdw_explain_state; + + fdw_private = plan->fdw_private; + sql = strVal(list_nth(fdw_private, FdwDirectModifyPrivateUpdateSql)); if (es->verbose) - { - fdw_private = ((ForeignScan *) node->ss.ps.plan)->fdw_private; - sql = strVal(list_nth(fdw_private, FdwDirectModifyPrivateUpdateSql)); ExplainPropertyText("Remote SQL", sql, es); - } + + pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id); + + if (pgfdw_explain_state && pgfdw_explain_state->remote_plans) + explain_remote_query(node->ss.ps.plan->plan_node_id, + pgfdw_explain_state, + RelationGetRelid(node->ss.ss_currentRelation), + sql, + plan->fdw_exprs != NIL); } /* @@ -9296,3 +9534,181 @@ get_batch_size_option(Relation rel) return batch_size; } + +/* + * Get the PgFdwExplainState structure from an ExplainState; if there is + * none, create one, attach it to the ExplainState, and return it. + */ +static PgFdwExplainState * +pgfdw_ensure_options(ExplainState *es) +{ + PgFdwExplainState *pgfdw_explain_state; + + pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id); + + if (pgfdw_explain_state == NULL) + { + pgfdw_explain_state = palloc0(sizeof(PgFdwExplainState)); + SetExplainExtensionState(es, postgres_fdw_extension_id, pgfdw_explain_state); + pgfdw_explain_state->all_remote_plans = NIL; + } + + return pgfdw_explain_state; +} + +/* + * Parse handler for EXPLAIN (REMOTE_PLANS). + */ +static void +pgfdw_remote_plans_apply(ExplainState *es, DefElem *opt, ParseState *pstate) +{ + PgFdwExplainState *options = pgfdw_ensure_options(es); + + options->remote_plans = defGetBoolean(opt); +} + +static void +postgresExplainValidateOptions(ExplainState *es, List *options, ParseState *pstate) +{ + ListCell *lc; + PgFdwExplainState *pgfdw_explain_state = NULL; + + if (prev_explain_validate_options_hook) + (*prev_explain_validate_options_hook) (es, options, pstate); + + foreach(lc, options) + { + DefElem *opt = (DefElem *) lfirst(lc); + + if (strcmp(opt->defname, "remote_plans") == 0) + { + if (defGetBoolean(opt) && es->analyze) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("EXPLAIN options REMOTE_PLANS and ANALYZE cannot be used together")); + + pgfdw_explain_state = pgfdw_ensure_options(es); + pgfdw_explain_state->options = options; + } + } +} + +static void +postgresExplainPerNode(PlanState *planstate, List *ancestors, + const char *relationship, const char *plan_name, + ExplainState *es) +{ + PgFdwExplainState *pgfdw_explain_state; + + if (prev_explain_per_node_hook) + (*prev_explain_per_node_hook) (planstate, ancestors, relationship, + plan_name, es); + + pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id); + + if (pgfdw_explain_state == NULL || + !pgfdw_explain_state->remote_plans) + return; + + ExplainPropertyInteger("Plan Node ID", NULL, planstate->plan->plan_node_id, es); +} + +static void +pgfdwFormatRemotePlan(PgFdwExplainRemotePlans *explain, + ExplainState *es, + int plan_node_id) +{ + char *token; + StringInfoData remote_plan_name; + + initStringInfo(&remote_plan_name); + appendStringInfo(&remote_plan_name, "Plan Node ID %d", plan_node_id); + + ExplainOpenGroup(remote_plan_name.data, remote_plan_name.data, false, es); + + if (es->format == EXPLAIN_FORMAT_TEXT) + { + appendStringInfo(es->str, "Plan Node ID %d:", plan_node_id); + appendStringInfoString(es->str, "\n"); + } + + while ((token = strsep(&explain->explain_plan.data, "\n")) != NULL) + { + if (es->format == EXPLAIN_FORMAT_JSON || + es->format == EXPLAIN_FORMAT_YAML) + appendStringInfoString(es->str, "\n"); + + appendStringInfoSpaces(es->str, (es->indent == 0) ? 2 : es->indent * 2); + appendStringInfoString(es->str, token); + + if (es->format == EXPLAIN_FORMAT_XML || + es->format == EXPLAIN_FORMAT_TEXT) + appendStringInfoString(es->str, "\n"); + } + + ExplainCloseGroup(remote_plan_name.data, remote_plan_name.data, false, es); + pfree(remote_plan_name.data); +} + +static void +postgresExplainPerPlan(PlannedStmt *plannedstmt, + IntoClause *into, + ExplainState *es, + const char *queryString, + ParamListInfo params, + QueryEnvironment *queryEnv) +{ + ListCell *lc; + PgFdwExplainState *pgfdw_explain_state; + + if (prev_explain_per_plan_hook) + (*prev_explain_per_plan_hook) (plannedstmt, into, es, queryString, + params, queryEnv); + + pgfdw_explain_state = GetExplainExtensionState(es, postgres_fdw_extension_id); + + if (pgfdw_explain_state == NULL || + pgfdw_explain_state->all_remote_plans == NIL || + !pgfdw_explain_state->remote_plans) + return; + + ExplainOpenGroup("Remote Plans", "Remote Plans", true, es); + if (es->format == EXPLAIN_FORMAT_TEXT) + { + appendStringInfo(es->str, "Remote Plans:\n"); + appendStringInfo(es->str, "-------------\n"); + } + + /* Process every remote plan captured */ + foreach(lc, pgfdw_explain_state->all_remote_plans) + { + PgFdwExplainRemotePlans *explain = (PgFdwExplainRemotePlans *) lfirst(lc); + + pgfdwFormatRemotePlan(explain, + es, + explain->plan_node_id); + } + + ExplainCloseGroup("Remote Plans", "Remote Plans", true, es); +} + +/* + * Register EXPLAIN (REMOTE_PLANS) and install the hooks that implement it. + * Called from _PG_init. + */ +void +postgres_fdw_explain_init(void) +{ + postgres_fdw_extension_id = GetExplainExtensionId("postgres_fdw"); + + RegisterExtensionExplainOption("remote_plans", pgfdw_remote_plans_apply, + GUCCheckBooleanExplainOption); + + /* Install our EXPLAIN hooks, saving the previous ones for chaining. */ + prev_explain_per_node_hook = explain_per_node_hook; + explain_per_node_hook = postgresExplainPerNode; + prev_explain_per_plan_hook = explain_per_plan_hook; + explain_per_plan_hook = postgresExplainPerPlan; + prev_explain_validate_options_hook = explain_validate_options_hook; + explain_validate_options_hook = postgresExplainValidateOptions; +} diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h index da7da1c2ea9..f1eea65fb21 100644 --- a/contrib/postgres_fdw/postgres_fdw.h +++ b/contrib/postgres_fdw/postgres_fdw.h @@ -165,6 +165,7 @@ typedef enum PgFdwSamplingMethod extern int set_transmission_modes(void); extern void reset_transmission_modes(int nestlevel); extern void process_pending_request(AsyncRequest *areq); +extern void postgres_fdw_explain_init(void); /* in connection.c */ extern PGconn *GetConnection(UserMapping *user, bool will_prep_stmt, diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql index eaeb90485e8..063d6c5f21b 100644 --- a/contrib/postgres_fdw/sql/postgres_fdw.sql +++ b/contrib/postgres_fdw/sql/postgres_fdw.sql @@ -311,6 +311,15 @@ SELECT * FROM ft1 t1 WHERE t1.c3 = (SELECT MAX(c3) FROM ft2 t2) ORDER BY c1; WITH t1 AS (SELECT * FROM ft1 WHERE c1 <= 10) SELECT t2.c1, t2.c2, t2.c3, t2.c4 FROM t1, ft2 t2 WHERE t1.c1 = t2.c1 ORDER BY t1.c1; -- fixed values SELECT 'fixed', NULL FROM ft1 t1 WHERE c1 = 1; +-- with WHERE clause and REMOTE_PLANS +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) SELECT * FROM ft1 t1 WHERE t1.c1 = 101; +-- an error raised by the remote EXPLAIN is reported to the client; without +-- REMOTE_PLANS this plan never contacts the remote server at all +CREATE FOREIGN TABLE remote_plans_bad (c1 int) + SERVER loopback OPTIONS (schema_name 'S 1', table_name 'no_such_table'); +EXPLAIN (COSTS OFF) SELECT * FROM remote_plans_bad; +EXPLAIN (REMOTE_PLANS, COSTS OFF) SELECT * FROM remote_plans_bad; +DROP FOREIGN TABLE remote_plans_bad; -- Test forcing the remote server to produce sorted data for a merge join. SET enable_hashjoin TO false; SET enable_nestloop TO false; @@ -338,6 +347,19 @@ SELECT t1."C 1", t2.c1, t3.c1 FROM "S 1"."T 1" t1 left join ft1 t2 full join ft2 EXPLAIN (VERBOSE, COSTS OFF) SELECT t1."C 1", t2.c1, t3.c1 FROM "S 1"."T 1" t1 full join ft1 t2 full join ft2 t3 on (t2.c1 = t3.c1) on (t3.c1 = t1."C 1") OFFSET 100 LIMIT 10; SELECT t1."C 1", t2.c1, t3.c1 FROM "S 1"."T 1" t1 full join ft1 t2 full join ft2 t3 on (t2.c1 = t3.c1) on (t3.c1 = t1."C 1") OFFSET 100 LIMIT 10; + +-- Join push-down test +-- Ensure join conditions are pushed down to the foreign server +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) + SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) WHERE t1.c2 = 10; + +-- Tables on multiple foreign connections: ft5 lives on server loopback and +-- ft6 on server loopback2, so the join cannot be pushed down as a single +-- foreign join. Each side is scanned over its own connection, so REMOTE_PLANS +-- must collect and print one remote plan per connection. +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) + SELECT t1.c1, t2.c1 FROM ft5 t1 JOIN ft6 t2 ON (t1.c1 = t2.c1) WHERE t1.c1 = 10; + RESET enable_hashjoin; RESET enable_nestloop; @@ -379,6 +401,11 @@ EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ft1 t1 WHERE c8 = 'foo'; -- can't be EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2; SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2; +-- REMOTE_PLANS over a parameterized foreign scan: the deparsed remote SQL +-- carries a $1 placeholder, which only plans on the remote side because +-- REMOTE_PLANS forces GENERIC_PLAN. +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) + SELECT * FROM "S 1"."T 1" a, ft2 b WHERE a."C 1" = 47 AND b.c1 = a.c2; -- check both safe and unsafe join conditions EXPLAIN (VERBOSE, COSTS OFF) @@ -1836,6 +1863,19 @@ SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1) ORDER BY ft1.c1 LIMIT 5; +-- EXPLAIN with REMOTE_PLANS +-- REMOTE_PLANS cannot be combined with ANALYZE +EXPLAIN (REMOTE_PLANS, COSTS OFF, ANALYZE) +SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE + ft1.c1 IN ( + SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1) + ORDER BY ft1.c1 LIMIT 5; +EXPLAIN (REMOTE_PLANS, COSTS OFF) +SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE + ft1.c1 IN ( + SELECT ft2.c1 FROM ft2 JOIN ft4 ON ft2.c1 = ft4.c1) + ORDER BY ft1.c1 LIMIT 5; + -- =================================================================== -- test writable foreign table stuff -- =================================================================== @@ -1919,6 +1959,9 @@ DELETE FROM fpo_part_parent FOR PORTION OF c4 FROM '2024-06-01' TO '2024-06-15' WHERE c2 = 1; -- okay SELECT c1, c2, c3, c4 FROM fpo_part_local ORDER BY c4; DROP TABLE fpo_part_parent; +-- test write on foreign tables with REMOTE_PLANS +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) +UPDATE ft2 SET c2 = c2 + 300 WHERE c1 % 10 = 3; -- Test UPDATE/DELETE with RETURNING on a three-table join INSERT INTO ft2 (c1,c2,c3) @@ -4571,6 +4614,17 @@ INSERT INTO insert_tbl (SELECT * FROM local_tbl UNION ALL SELECT * FROM remote_t SELECT * FROM insert_tbl ORDER BY a; +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) +INSERT INTO insert_tbl (SELECT * FROM local_tbl UNION ALL SELECT * FROM remote_tbl); + +-- An INSERT whose target list contains only generated columns has no +-- placeholders, despite having a nonempty targetAttrs list. +CREATE TABLE generated_tbl (a int GENERATED ALWAYS AS (1) STORED); +CREATE FOREIGN TABLE generated_ft (a int GENERATED ALWAYS AS (1) STORED) + SERVER loopback OPTIONS (table_name 'generated_tbl'); +EXPLAIN (REMOTE_PLANS, VERBOSE, COSTS OFF) +INSERT INTO generated_ft DEFAULT VALUES; + -- Check with direct modify EXPLAIN (VERBOSE, COSTS OFF) WITH t AS (UPDATE remote_tbl SET c = c || c RETURNING *) @@ -4584,8 +4638,10 @@ DELETE FROM join_tbl; DROP TABLE local_tbl; DROP FOREIGN TABLE remote_tbl; DROP FOREIGN TABLE insert_tbl; +DROP FOREIGN TABLE generated_ft; DROP TABLE base_tbl3; DROP TABLE base_tbl4; +DROP TABLE generated_tbl; RESET enable_mergejoin; RESET enable_hashjoin; diff --git a/contrib/postgres_fdw/sql/remote_plans_formats.sql b/contrib/postgres_fdw/sql/remote_plans_formats.sql new file mode 100644 index 00000000000..0d7e25ce9a5 --- /dev/null +++ b/contrib/postgres_fdw/sql/remote_plans_formats.sql @@ -0,0 +1,19 @@ +-- =================================================================== +-- test EXPLAIN (REMOTE_PLANS) in the non-text output formats +-- =================================================================== +-- The behaviour of REMOTE_PLANS itself is tested in postgres_fdw.sql, next +-- to the tests for the plans it reports on. This file only covers how the +-- collected remote plans are embedded in each output format. +-- This runs after postgres_fdw.sql in the same database and reuses the +-- server, user mapping and foreign tables created there. + +LOAD 'postgres_fdw'; + +EXPLAIN (REMOTE_PLANS, FORMAT JSON, COSTS OFF) +SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101; + +EXPLAIN (REMOTE_PLANS, FORMAT XML, COSTS OFF) +SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101; + +EXPLAIN (REMOTE_PLANS, FORMAT YAML, COSTS OFF) +SELECT c1 FROM ft1 t1 WHERE t1.c1 = 101; diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml index 80f79c017d6..b8b04552930 100644 --- a/doc/src/sgml/postgres-fdw.sgml +++ b/doc/src/sgml/postgres-fdw.sgml @@ -1229,6 +1229,107 @@ CREATE SUBSCRIPTION my_subscription SERVER subscription_server PUBLICATION testp The query that is actually sent to the remote server for execution can be examined using EXPLAIN VERBOSE. + + + In addition to the remote query itself, the plan that the remote server + chooses for that query can be examined with the + REMOTE_PLANS option of EXPLAIN. + For every foreign scan or foreign modification in the local plan, + postgres_fdw runs EXPLAIN on the + remote server for the corresponding remote query and prints the result in a + Remote Plans section, keyed by the plan node id of the + local node. For example: + +EXPLAIN (VERBOSE, REMOTE_PLANS) SELECT * FROM foreign_tbl WHERE id = 42; + QUERY PLAN +----------------------------------------------------------- + Foreign Scan on public.foreign_tbl + Output: id, val + Remote SQL: SELECT id, val FROM public.tbl WHERE ((id = 42)) + Plan Node ID: 0 + Remote Plans: + ------------- + Plan Node ID 0: + Index Scan using tbl_pkey on public.tbl + Output: id, val + Index Cond: (tbl.id = 42) + + When the local plan uses a parameterized foreign scan (for example, as + the inner side of a nested loop join), the remote SQL contains parameter + placeholders, which a plain EXPLAIN on the remote server + could not plan; it would fail with there is no parameter $1. + REMOTE_PLANS therefore asks the remote server for a + generic plan in this case: + +EXPLAIN (VERBOSE, REMOTE_PLANS, COSTS OFF) + SELECT * FROM local_tbl a, foreign_tbl b WHERE a.id = 1 AND b.id = a.val; + QUERY PLAN +----------------------------------------------------------- + Nested Loop + Output: a.id, a.val, b.id, b.val + -> Seq Scan on public.local_tbl a + Output: a.id, a.val + Filter: (a.id = 1) + -> Foreign Scan on public.foreign_tbl b + Output: b.id, b.val + Remote SQL: SELECT id, val FROM public.tbl WHERE ((id = $1::integer)) + Plan Node ID: 1 + Remote Plans: + ------------- + Plan Node ID 1: + Index Scan using tbl_pkey on public.tbl + Output: id, val + Index Cond: (tbl.id = $1) + + + + + The REMOTE_PLANS option is registered when the + postgres_fdw library is loaded into the session. The + library is loaded on demand, the first time the session plans or executes a + query against a foreign table, so in a session that has not yet touched a + foreign table the option is not recognized and + EXPLAIN reports + unrecognized EXPLAIN option "remote_plans". Note that + CREATE EXTENSION postgres_fdw alone does not load the + library into the current session. To make the option available up front, + use LOAD 'postgres_fdw', or list the library in + or + . + The following restrictions apply: + + + + It cannot be combined with ANALYZE; doing so raises an + error. The remote query is only planned, never executed, so the foreign + server is not affected and no remote rows are fetched. + + + + + For a remote query containing parameter placeholders, the remote server + must be running PostgreSQL 16 or later, + because REMOTE_PLANS uses the + GENERIC_PLAN option of EXPLAIN, + which was introduced in that release. As shown in the parameterized + example above, the plan shown for such a query is a generic plan, which + may differ from the plan the remote server actually uses at execution + time when specific parameter values are available. For a remote query + without parameter placeholders, GENERIC_PLAN is not + requested and this version restriction does not apply. + + + + + All EXPLAIN options specified by the user (other than + REMOTE_PLANS itself and + GENERIC_PLAN) are forwarded to the remote server. + If the remote server is running an older version that does not recognize + a forwarded option, it will report an error. + + + + diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index cdf5a7d0b11..168fdc7cef2 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -2286,6 +2286,8 @@ PgChecksumMode PgFdwAnalyzeState PgFdwConnState PgFdwDirectModifyState +PgFdwExplainRemotePlans +PgFdwExplainState PgFdwModifyState PgFdwOption PgFdwPathExtraData -- 2.53.0