From 446d44b8348b85966e5604b5bb03a58ffd840b47 Mon Sep 17 00:00:00 2001
From: Sagar Shedge <sagar.shedge92@gmail.com>
Date: Sun, 6 Sep 2026 07:09:55 +0530
Subject: [PATCH v4] postgres_fdw: Push down WITH TIES for known remote servers

Previously, FETCH FIRST ... WITH TIES was always kept local because the
remote server might predate version 13, which added support for the
clause.  Plain LIMIT cannot preserve the additional tied rows.

Use an existing cached connection to determine whether the remote server
supports WITH TIES, without opening a connection or doing network I/O
for this version check.  Remote estimates may already have populated the
cache during planning.  Keep the restriction local when no suitable
mapping or cached version is available, or when the relation does not
belong to a single foreign server.

A consequence of keying this off connection history is that the pushdown
decision is not a pure function of the query and the remote server: the
same query planned twice in one session can come out two different ways
(WITH TIES pushed down vs. a local Limit) purely because some unrelated
query against the same server opened a connection in between.  This is
analogous to existing session-dependent plan variability in Postgres,
e.g. a prepared statement's plan shape depending on prior executions in
the same session (custom vs. generic plan).

A partitioned parent has no FDW routine, even when all its foreign
partitions use the same server.  Its WITH TIES restriction therefore
stays above the local Append or MergeAppend, where it can apply to the
combined result.

Require nonempty remote sort keys as well as shippable ordering.  The
planner can remove every sort key as redundant, for example when a WHERE
clause fixes the ordering expression.  In that case the deparser emits
no ORDER BY and a remote WITH TIES clause would be invalid.

Emit FETCH FIRST ... WITH TIES instead of LIMIT, with OFFSET first in
SQL-standard order.  Parenthesize the count and offset expressions so
that casts added by the deparser are valid in these grammar positions.
Add regression coverage for cached-version pushdown, local fallback,
OFFSET, foreign partitions on the same server, redundant sort keys, and
EXPLAIN without a user mapping.

Co-authored-by: Jinqing Kuang <kuangjinqingcn@gmail.com>
Discussion: https://postgr.es/m/CAPhYifHu_Nd+YoAg0iWfCCO+6eGo5nzbQNyOm=uxXTcvKatccw@mail.gmail.com
---
Changes since v3:
- Explain why a partitioned parent keeps WITH TIES local, even when all
  foreign partitions use the same server.
- Add coverage for ties spanning those partitions and OFFSET into the tied
  group, with a cached connection.

 contrib/postgres_fdw/connection.c             |  30 +++
 contrib/postgres_fdw/deparse.c                |  39 +++-
 .../postgres_fdw/expected/postgres_fdw.out    | 216 +++++++++++++++++-
 contrib/postgres_fdw/postgres_fdw.c           |  51 ++++-
 contrib/postgres_fdw/postgres_fdw.h           |   1 +
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  85 ++++++-
 6 files changed, 404 insertions(+), 18 deletions(-)

diff --git a/contrib/postgres_fdw/connection.c b/contrib/postgres_fdw/connection.c
index b5d4cf3dccc..05198279ef2 100644
--- a/contrib/postgres_fdw/connection.c
+++ b/contrib/postgres_fdw/connection.c
@@ -1030,6 +1030,36 @@ ReleaseConnection(PGconn *conn)
 	 */
 }
 
+/*
+ * Return the server version number of the already-cached connection for
+ * "user", if one exists, or 0 if there is none (in which case the caller
+ * must not assume anything about the remote server's version).
+ *
+ * This never establishes a new connection and never does any network I/O:
+ * it only consults the connection cache and, if a live entry is found,
+ * reads the version number libpq already recorded during that connection's
+ * startup handshake.  Planning can use this information without opening
+ * another connection.
+ */
+int
+GetCachedConnectionVersion(UserMapping *user)
+{
+	bool		found;
+	ConnCacheKey key;
+	ConnCacheEntry *entry;
+
+	if (ConnectionHash == NULL)
+		return 0;
+
+	key = user->umid;
+	entry = (ConnCacheEntry *) hash_search(ConnectionHash, &key, HASH_FIND,
+										   &found);
+	if (!found || entry->conn == NULL || entry->invalidated)
+		return 0;
+
+	return PQserverVersion(entry->conn);
+}
+
 /*
  * Assign a "unique" number for a cursor.
  *
diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index ff9fe0f87e4..74beadc418b 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -4232,15 +4232,44 @@ appendLimitClause(deparse_expr_cxt *context)
 	/* Make sure any constants in the exprs are printed portably */
 	nestlevel = set_transmission_modes();
 
-	if (root->parse->limitCount)
+	if (root->parse->limitOption == LIMIT_OPTION_WITH_TIES)
 	{
-		appendStringInfoString(buf, " LIMIT ");
+		/*
+		 * Plain LIMIT has no way to express WITH TIES, so use the
+		 * SQL-standard FETCH clause instead.  Emit OFFSET before FETCH as
+		 * required by the SQL standard.
+		 *
+		 * Unlike LIMIT/OFFSET, the value in this position is restricted to
+		 * "c_expr" rather than a full "a_expr" (see select_fetch_first_value
+		 * in gram.y), which notably disallows the "::type" cast decoration
+		 * deparseExpr() adds to constants for portability.  Parenthesize the
+		 * value to work around that; c_expr explicitly allows a parenthesized
+		 * a_expr, so this is valid regardless of what kind of expression it
+		 * turns out to be.
+		 */
+		if (root->parse->limitOffset)
+		{
+			appendStringInfoString(buf, " OFFSET (");
+			deparseExpr((Expr *) root->parse->limitOffset, context);
+			appendStringInfoString(buf, ") ROWS");
+		}
+		Assert(root->parse->limitCount);
+		appendStringInfoString(buf, " FETCH FIRST (");
 		deparseExpr((Expr *) root->parse->limitCount, context);
+		appendStringInfoString(buf, ") ROWS WITH TIES");
 	}
-	if (root->parse->limitOffset)
+	else
 	{
-		appendStringInfoString(buf, " OFFSET ");
-		deparseExpr((Expr *) root->parse->limitOffset, context);
+		if (root->parse->limitCount)
+		{
+			appendStringInfoString(buf, " LIMIT ");
+			deparseExpr((Expr *) root->parse->limitCount, context);
+		}
+		if (root->parse->limitOffset)
+		{
+			appendStringInfoString(buf, " OFFSET ");
+			deparseExpr((Expr *) root->parse->limitOffset, context);
+		}
 	}
 
 	reset_transmission_modes(nestlevel);
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index a6295674daf..f6035ae82ce 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -1087,17 +1087,17 @@ SELECT * FROM ft1 t1 WHERE t1.c1 === t1.c2 order by t1.c2 limit 1;
   1 |  1 | 00001 | Fri Jan 02 00:00:00 1970 PST | Fri Jan 02 00:00:00 1970 | 1  | 1          | foo
 (1 row)
 
--- Ensure we don't ship FETCH FIRST .. WITH TIES
+-- Ensure we ship FETCH FIRST .. WITH TIES once the remote server's version
+-- is known (i.e., a connection to it is already cached in this session, as
+-- is the case here due to preceding tests)
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 FETCH FIRST 2 ROWS WITH TIES;
-                                           QUERY PLAN                                            
--------------------------------------------------------------------------------------------------
- Limit
+                                                            QUERY PLAN                                                            
+----------------------------------------------------------------------------------------------------------------------------------
+ Foreign Scan on public.ft1 t1
    Output: c2
-   ->  Foreign Scan on public.ft1 t1
-         Output: c2
-         Remote SQL: SELECT c2 FROM "S 1"."T 1" WHERE (("C 1" > 960)) ORDER BY c2 ASC NULLS LAST
-(5 rows)
+   Remote SQL: SELECT c2 FROM "S 1"."T 1" WHERE (("C 1" > 960)) ORDER BY c2 ASC NULLS LAST FETCH FIRST (2::bigint) ROWS WITH TIES
+(3 rows)
 
 SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 FETCH FIRST 2 ROWS WITH TIES;
  c2 
@@ -1108,6 +1108,206 @@ SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 FETCH FIRST 2 ROWS WIT
   0
 (4 rows)
 
+-- Same, but combined with OFFSET, emitted before FETCH FIRST in SQL-standard
+-- order.  Skipping into the middle of a tied group must not drop any of the
+-- remaining ties.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 OFFSET 1 FETCH FIRST 2 ROWS WITH TIES;
+                                                                        QUERY PLAN                                                                        
+----------------------------------------------------------------------------------------------------------------------------------------------------------
+ Foreign Scan on public.ft1 t1
+   Output: c2
+   Remote SQL: SELECT c2 FROM "S 1"."T 1" WHERE (("C 1" > 960)) ORDER BY c2 ASC NULLS LAST OFFSET (1::bigint) ROWS FETCH FIRST (2::bigint) ROWS WITH TIES
+(3 rows)
+
+SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 OFFSET 1 FETCH FIRST 2 ROWS WITH TIES;
+ c2 
+----
+  0
+  0
+  0
+(3 rows)
+
+-- Ensure we never ship FETCH FIRST .. WITH TIES for a query whose result
+-- combines rows from more than one foreign server (here, a join between
+-- ft5 on "loopback" and ft6 on "loopback2"), regardless of whether either
+-- server's version is known; there's no single remote query to push the
+-- FETCH clause into, so it must stay local
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT ft5.c1, ft5.c2 FROM ft5 JOIN ft6 USING (c1)
+  ORDER BY ft5.c2 FETCH FIRST 2 ROWS WITH TIES;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Limit
+   Output: ft5.c1, ft5.c2
+   ->  Nested Loop
+         Output: ft5.c1, ft5.c2
+         Join Filter: (ft5.c1 = ft6.c1)
+         ->  Foreign Scan on public.ft5
+               Output: ft5.c1, ft5.c2, ft5.c3
+               Remote SQL: SELECT c1, c2 FROM "S 1"."T 4" ORDER BY c2 ASC NULLS LAST
+         ->  Materialize
+               Output: ft6.c1
+               ->  Foreign Scan on public.ft6
+                     Output: ft6.c1
+                     Remote SQL: SELECT c1 FROM "S 1"."T 4"
+(13 rows)
+
+-- Two independently limited scans on different foreign servers, combined
+-- locally via UNION ALL: each side's FETCH FIRST .. WITH TIES pushdown
+-- decision is made independently based on its own server's cached
+-- connection, with no coordination needed between them.  ft5's server
+-- (loopback) is already warmed up by many earlier tests, so that side
+-- pushes the FETCH clause down; ft6's server (loopback2) has not been
+-- connected to yet, so that side falls back to a local Limit.
+EXPLAIN (VERBOSE, COSTS OFF)
+(SELECT c1, c2 FROM ft6 ORDER BY c2 FETCH FIRST 2 ROWS WITH TIES)
+UNION ALL
+(SELECT c1, c2 FROM ft5 ORDER BY c2 FETCH FIRST 2 ROWS WITH TIES);
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
+ Append
+   ->  Limit
+         Output: ft6.c1, ft6.c2
+         ->  Foreign Scan on public.ft6
+               Output: ft6.c1, ft6.c2
+               Remote SQL: SELECT c1, c2 FROM "S 1"."T 4" ORDER BY c2 ASC NULLS LAST
+   ->  Foreign Scan on public.ft5
+         Output: ft5.c1, ft5.c2
+         Remote SQL: SELECT c1, c2 FROM "S 1"."T 4" ORDER BY c2 ASC NULLS LAST FETCH FIRST (2::bigint) ROWS WITH TIES
+(9 rows)
+
+-- WITH TIES must apply to the combined result of foreign partitions, even
+-- when they use the same server and its connection is already cached.
+CREATE TABLE with_ties_1 (p int, k int);
+CREATE TABLE with_ties_2 (p int, k int);
+INSERT INTO with_ties_1 VALUES (1, 1), (1, 2), (1, 2), (1, 4);
+INSERT INTO with_ties_2 VALUES (2, 2), (2, 2), (2, 3), (2, 5);
+CREATE TABLE with_ties (p int, k int) PARTITION BY LIST (p);
+CREATE FOREIGN TABLE with_ties_p1 PARTITION OF with_ties FOR VALUES IN (1)
+  SERVER loopback OPTIONS (table_name 'with_ties_1');
+CREATE FOREIGN TABLE with_ties_p2 PARTITION OF with_ties FOR VALUES IN (2)
+  SERVER loopback OPTIONS (table_name 'with_ties_2');
+-- The boundary ties span both partitions; keep a Limit above Merge Append.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT k FROM with_ties ORDER BY k FETCH FIRST 2 ROWS WITH TIES;
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ Limit
+   Output: with_ties.k
+   ->  Merge Append
+         Sort Key: with_ties.k
+         ->  Foreign Scan on public.with_ties_p1 with_ties_1
+               Output: with_ties_1.k
+               Remote SQL: SELECT k FROM public.with_ties_1 ORDER BY k ASC NULLS LAST
+         ->  Foreign Scan on public.with_ties_p2 with_ties_2
+               Output: with_ties_2.k
+               Remote SQL: SELECT k FROM public.with_ties_2 ORDER BY k ASC NULLS LAST
+(10 rows)
+
+SELECT k FROM with_ties ORDER BY k FETCH FIRST 2 ROWS WITH TIES;
+ k 
+---
+ 1
+ 2
+ 2
+ 2
+ 2
+(5 rows)
+
+-- OFFSET skips into the tied group in the globally ordered result.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT k FROM with_ties ORDER BY k OFFSET 2 FETCH FIRST 1 ROW WITH TIES;
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ Limit
+   Output: with_ties.k
+   ->  Merge Append
+         Sort Key: with_ties.k
+         ->  Foreign Scan on public.with_ties_p1 with_ties_1
+               Output: with_ties_1.k
+               Remote SQL: SELECT k FROM public.with_ties_1 ORDER BY k ASC NULLS LAST
+         ->  Foreign Scan on public.with_ties_p2 with_ties_2
+               Output: with_ties_2.k
+               Remote SQL: SELECT k FROM public.with_ties_2 ORDER BY k ASC NULLS LAST
+(10 rows)
+
+SELECT k FROM with_ties ORDER BY k OFFSET 2 FETCH FIRST 1 ROW WITH TIES;
+ k 
+---
+ 2
+ 2
+ 2
+(3 rows)
+
+DROP TABLE with_ties;
+DROP TABLE with_ties_1, with_ties_2;
+-- Keep WITH TIES local when all ORDER BY keys are redundant.  ft2 uses
+-- remote estimates, so invalid remote SQL would fail during planning.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT c2, count(*) FROM ft2 GROUP BY c2
+  ORDER BY (1+1) FETCH FIRST 2 ROWS WITH TIES;
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Limit
+   Output: c2, (count(*)), 2
+   ->  Foreign Scan
+         Output: c2, (count(*)), 2
+         Relations: Aggregate on (public.ft2)
+         Remote SQL: SELECT c2, count(*), 2 FROM "S 1"."T 1" GROUP BY 1
+(6 rows)
+
+SELECT count(*) FROM (
+  SELECT c2, count(*) FROM ft2 GROUP BY c2
+    ORDER BY (1+1) FETCH FIRST 2 ROWS WITH TIES
+) s;
+ count 
+-------
+    10
+(1 row)
+
+-- A restriction can also make the ORDER BY key redundant.  All four
+-- matching groups tie, and OFFSET must still skip one of them.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT c1, count(*) FROM ft2 WHERE c1 > 960 AND c2 = 1 GROUP BY c1, c2
+  ORDER BY c2 OFFSET 1 FETCH FIRST 2 ROWS WITH TIES;
+                                                           QUERY PLAN                                                           
+--------------------------------------------------------------------------------------------------------------------------------
+ Limit
+   Output: c1, (count(*)), c2
+   ->  GroupAggregate
+         Output: c1, count(*), c2
+         Group Key: ft2.c1
+         ->  Foreign Scan on public.ft2
+               Output: c1, c2
+               Remote SQL: SELECT "C 1", c2 FROM "S 1"."T 1" WHERE (("C 1" > 960)) AND ((c2 = 1)) ORDER BY "C 1" ASC NULLS LAST
+(8 rows)
+
+SELECT count(*) FROM (
+  SELECT c1, count(*) FROM ft2 WHERE c1 > 960 AND c2 = 1 GROUP BY c1, c2
+    ORDER BY c2 OFFSET 1 FETCH FIRST 2 ROWS WITH TIES
+) s;
+ count 
+-------
+     3
+(1 row)
+
+-- EXPLAIN with local estimates does not require a user mapping.
+CREATE SERVER no_mapping FOREIGN DATA WRAPPER postgres_fdw;
+CREATE FOREIGN TABLE ft_no_mapping (a int) SERVER no_mapping;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT a FROM ft_no_mapping ORDER BY a FETCH FIRST 2 ROWS WITH TIES;
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
+ Limit
+   Output: a
+   ->  Foreign Scan on public.ft_no_mapping
+         Output: a
+         Remote SQL: SELECT a FROM public.ft_no_mapping ORDER BY a ASC NULLS LAST
+(5 rows)
+
+DROP FOREIGN TABLE ft_no_mapping;
+DROP SERVER no_mapping;
 -- Test CASE pushdown
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT c1,c2,c3 FROM ft2 WHERE CASE WHEN c1 > 990 THEN c1 END < 1000 ORDER BY c1;
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index c731ee199e2..0b3a38815e4 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -8514,12 +8514,55 @@ add_foreign_final_paths(PlannerInfo *root, RelOptInfo *input_rel,
 	 * determined to be safe to push down before we get here.  So in that case
 	 * the FETCH clause is safe to push down with ORDER BY if the remote
 	 * server is v13 or later, but if not, the remote query will fail entirely
-	 * for lack of support for it.  Since we do not currently have a way to do
-	 * a remote-version check (without accessing the remote server), disable
-	 * pushing the FETCH clause for now.
+	 * for lack of support for it.  Do not open a connection just to check the
+	 * remote server's version.  If a connection is already cached, perhaps
+	 * from an earlier query or remote estimates during this planning, its
+	 * version is available without additional network I/O.  Push the FETCH
+	 * clause down only when the cached version confirms support; otherwise
+	 * keep it local.
+	 *
+	 * Because of this, the pushdown decision depends on this backend's
+	 * connection history rather than solely on the query and the remote
+	 * server: the identical query planned twice in the same session can come
+	 * out with WITH TIES pushed down the second time purely because some
+	 * unrelated query against the same server opened a connection in between,
+	 * with nothing about the query itself having changed.  This is accepted
+	 * as the price of not opening a connection at plan time, but it means
+	 * EXPLAIN output for this query can differ from one planning to the next
+	 * within a session.
 	 */
 	if (parse->limitOption == LIMIT_OPTION_WITH_TIES)
-		return;
+	{
+		Oid			pushdown_userid;
+		UserMapping *user;
+
+		/*
+		 * All sort keys might have been removed as redundant.  Without an
+		 * ORDER BY clause in the remote query, WITH TIES is not valid.
+		 */
+		if (pathkeys == NIL)
+			return;
+
+		/*
+		 * Use the server for the whole relation, not an individual partition.
+		 * A partitioned parent has no FDW routine, even if all its foreign
+		 * partitions use the same server, so grouping_planner() does not call
+		 * us for it.  WITH TIES must still apply to the combined result above
+		 * the local Append or MergeAppend, rather than being replaced by
+		 * independent limits on the partitions.
+		 */
+		if (!OidIsValid(final_rel->serverid))
+			return;
+
+		pushdown_userid = OidIsValid(final_rel->userid) ?
+			final_rel->userid : GetUserId();
+		/* EXPLAIN without remote estimates need not have a user mapping. */
+		user = GetUserMappingExtended(pushdown_userid, final_rel->serverid,
+									  DEBUG1);
+
+		if (user == NULL || GetCachedConnectionVersion(user) < 130000)
+			return;
+	}
 
 	/*
 	 * Also, the LIMIT/OFFSET cannot be pushed down, if their expressions are
diff --git a/contrib/postgres_fdw/postgres_fdw.h b/contrib/postgres_fdw/postgres_fdw.h
index da7da1c2ea9..c2d03266607 100644
--- a/contrib/postgres_fdw/postgres_fdw.h
+++ b/contrib/postgres_fdw/postgres_fdw.h
@@ -170,6 +170,7 @@ extern void process_pending_request(AsyncRequest *areq);
 extern PGconn *GetConnection(UserMapping *user, bool will_prep_stmt,
 							 PgFdwConnState **state);
 extern void ReleaseConnection(PGconn *conn);
+extern int	GetCachedConnectionVersion(UserMapping *user);
 extern unsigned int GetCursorNumber(PGconn *conn);
 extern unsigned int GetPrepStmtNumber(PGconn *conn);
 extern void do_sql_command(PGconn *conn, const char *sql);
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index eaeb90485e8..70695636a01 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -442,11 +442,94 @@ EXPLAIN (VERBOSE, COSTS OFF)
   SELECT * FROM ft1 t1 WHERE t1.c1 === t1.c2 order by t1.c2 limit 1;
 SELECT * FROM ft1 t1 WHERE t1.c1 === t1.c2 order by t1.c2 limit 1;
 
--- Ensure we don't ship FETCH FIRST .. WITH TIES
+-- Ensure we ship FETCH FIRST .. WITH TIES once the remote server's version
+-- is known (i.e., a connection to it is already cached in this session, as
+-- is the case here due to preceding tests)
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 FETCH FIRST 2 ROWS WITH TIES;
 SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 FETCH FIRST 2 ROWS WITH TIES;
 
+-- Same, but combined with OFFSET, emitted before FETCH FIRST in SQL-standard
+-- order.  Skipping into the middle of a tied group must not drop any of the
+-- remaining ties.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 OFFSET 1 FETCH FIRST 2 ROWS WITH TIES;
+SELECT t1.c2 FROM ft1 t1 WHERE t1.c1 > 960 ORDER BY t1.c2 OFFSET 1 FETCH FIRST 2 ROWS WITH TIES;
+
+-- Ensure we never ship FETCH FIRST .. WITH TIES for a query whose result
+-- combines rows from more than one foreign server (here, a join between
+-- ft5 on "loopback" and ft6 on "loopback2"), regardless of whether either
+-- server's version is known; there's no single remote query to push the
+-- FETCH clause into, so it must stay local
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT ft5.c1, ft5.c2 FROM ft5 JOIN ft6 USING (c1)
+  ORDER BY ft5.c2 FETCH FIRST 2 ROWS WITH TIES;
+
+-- Two independently limited scans on different foreign servers, combined
+-- locally via UNION ALL: each side's FETCH FIRST .. WITH TIES pushdown
+-- decision is made independently based on its own server's cached
+-- connection, with no coordination needed between them.  ft5's server
+-- (loopback) is already warmed up by many earlier tests, so that side
+-- pushes the FETCH clause down; ft6's server (loopback2) has not been
+-- connected to yet, so that side falls back to a local Limit.
+EXPLAIN (VERBOSE, COSTS OFF)
+(SELECT c1, c2 FROM ft6 ORDER BY c2 FETCH FIRST 2 ROWS WITH TIES)
+UNION ALL
+(SELECT c1, c2 FROM ft5 ORDER BY c2 FETCH FIRST 2 ROWS WITH TIES);
+
+-- WITH TIES must apply to the combined result of foreign partitions, even
+-- when they use the same server and its connection is already cached.
+CREATE TABLE with_ties_1 (p int, k int);
+CREATE TABLE with_ties_2 (p int, k int);
+INSERT INTO with_ties_1 VALUES (1, 1), (1, 2), (1, 2), (1, 4);
+INSERT INTO with_ties_2 VALUES (2, 2), (2, 2), (2, 3), (2, 5);
+CREATE TABLE with_ties (p int, k int) PARTITION BY LIST (p);
+CREATE FOREIGN TABLE with_ties_p1 PARTITION OF with_ties FOR VALUES IN (1)
+  SERVER loopback OPTIONS (table_name 'with_ties_1');
+CREATE FOREIGN TABLE with_ties_p2 PARTITION OF with_ties FOR VALUES IN (2)
+  SERVER loopback OPTIONS (table_name 'with_ties_2');
+
+-- The boundary ties span both partitions; keep a Limit above Merge Append.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT k FROM with_ties ORDER BY k FETCH FIRST 2 ROWS WITH TIES;
+SELECT k FROM with_ties ORDER BY k FETCH FIRST 2 ROWS WITH TIES;
+
+-- OFFSET skips into the tied group in the globally ordered result.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT k FROM with_ties ORDER BY k OFFSET 2 FETCH FIRST 1 ROW WITH TIES;
+SELECT k FROM with_ties ORDER BY k OFFSET 2 FETCH FIRST 1 ROW WITH TIES;
+
+DROP TABLE with_ties;
+DROP TABLE with_ties_1, with_ties_2;
+
+-- Keep WITH TIES local when all ORDER BY keys are redundant.  ft2 uses
+-- remote estimates, so invalid remote SQL would fail during planning.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT c2, count(*) FROM ft2 GROUP BY c2
+  ORDER BY (1+1) FETCH FIRST 2 ROWS WITH TIES;
+SELECT count(*) FROM (
+  SELECT c2, count(*) FROM ft2 GROUP BY c2
+    ORDER BY (1+1) FETCH FIRST 2 ROWS WITH TIES
+) s;
+
+-- A restriction can also make the ORDER BY key redundant.  All four
+-- matching groups tie, and OFFSET must still skip one of them.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT c1, count(*) FROM ft2 WHERE c1 > 960 AND c2 = 1 GROUP BY c1, c2
+  ORDER BY c2 OFFSET 1 FETCH FIRST 2 ROWS WITH TIES;
+SELECT count(*) FROM (
+  SELECT c1, count(*) FROM ft2 WHERE c1 > 960 AND c2 = 1 GROUP BY c1, c2
+    ORDER BY c2 OFFSET 1 FETCH FIRST 2 ROWS WITH TIES
+) s;
+
+-- EXPLAIN with local estimates does not require a user mapping.
+CREATE SERVER no_mapping FOREIGN DATA WRAPPER postgres_fdw;
+CREATE FOREIGN TABLE ft_no_mapping (a int) SERVER no_mapping;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT a FROM ft_no_mapping ORDER BY a FETCH FIRST 2 ROWS WITH TIES;
+DROP FOREIGN TABLE ft_no_mapping;
+DROP SERVER no_mapping;
+
 -- Test CASE pushdown
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT c1,c2,c3 FROM ft2 WHERE CASE WHEN c1 > 990 THEN c1 END < 1000 ORDER BY c1;

base-commit: 7beefa8d46978341aed7ba14cc82bf37c0564948
-- 
2.43.0

