From 1c8a042f385ca8fbc5d8873b297224ab3a494ca9 Mon Sep 17 00:00:00 2001
From: Nik Samokhvalov <nik@postgres.ai>
Date: Mon, 14 Sep 2026 19:53:26 -0700
Subject: [PATCH] postgres_fdw: Make statistics import atomic

---
 .../postgres_fdw/expected/postgres_fdw.out    | 34 +++++++++++++++
 contrib/postgres_fdw/postgres_fdw.c           | 43 ++++++++++++++++++-
 contrib/postgres_fdw/sql/postgres_fdw.sql     | 29 +++++++++++++
 3 files changed, 104 insertions(+), 2 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 0cf84fee8..923b8d456 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -13270,6 +13270,38 @@ WHERE schemaname = 'public' AND tablename = 'dtest_ftable';
 ---------+-----------+-----------+-----------+------------+-----+-------------------+----+-------------
 (0 rows)
 
+-- A failed import must not leave partial attribute statistics behind for an
+-- empty sampling fallback.
+CREATE TABLE simport_atomicity_table (a int, b text);
+CREATE FOREIGN TABLE simport_atomicity_ftable (a int, b int)
+       SERVER loopback OPTIONS (table_name 'simport_atomicity_table',
+                                import_stats 'true');
+INSERT INTO simport_atomicity_table
+SELECT CASE WHEN i <= 9 THEN 11 ELSE 12 END,
+       CASE WHEN i <= 8 THEN '21' ELSE '22' END
+FROM generate_series(1, 10) i;
+ANALYZE simport_atomicity_table;
+ANALYZE simport_atomicity_ftable;
+TRUNCATE simport_atomicity_table;
+INSERT INTO simport_atomicity_table
+SELECT CASE WHEN i <= 7 THEN 111 ELSE 112 END,
+       CASE WHEN i <= 6 THEN 'not-an-integer-x' ELSE 'not-an-integer-y' END
+FROM generate_series(1, 10) i;
+ANALYZE simport_atomicity_table;
+DELETE FROM simport_atomicity_table;
+ANALYZE simport_atomicity_ftable;
+WARNING:  invalid input syntax for type integer: "not-an-integer-x"
+WARNING:  could not import statistics for foreign table "public.simport_atomicity_ftable" --- attribute statistics import failed for column "b" of this foreign table
+SELECT attname, most_common_vals::text
+FROM pg_stats
+WHERE schemaname = 'public' AND tablename = 'simport_atomicity_ftable'
+ORDER BY attname;
+ attname | most_common_vals 
+---------+------------------
+ a       | {11}
+ b       | {21,22}
+(2 rows)
+
 -- cleanup
 DROP FOREIGN TABLE simport_ftable;
 DROP FOREIGN TABLE simport_fview;
@@ -13279,6 +13311,8 @@ DROP FOREIGN TABLE simport_fpt;
 DROP TABLE simport_pt;
 DROP FOREIGN TABLE dtest_ftable;
 DROP TABLE dtest_table;
+DROP FOREIGN TABLE simport_atomicity_ftable;
+DROP TABLE simport_atomicity_table;
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 61e4e6fb2..775c407db 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -17,6 +17,7 @@
 #include "access/htup_details.h"
 #include "access/sysattr.h"
 #include "access/table.h"
+#include "access/xact.h"
 #include "catalog/pg_opfamily.h"
 #include "commands/defrem.h"
 #include "commands/explain_format.h"
@@ -51,6 +52,7 @@
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
+#include "utils/resowner.h"
 #include "utils/sampling.h"
 #include "utils/selfuncs.h"
 #include "utils/timestamp.h"
@@ -5538,8 +5540,45 @@ postgresImportForeignStatistics(Relation relation, List *va_cols, int elevel)
 								 &remstats, &remattrmap, &attrcnt);
 
 	if (ok)
-		ok = import_fetched_statistics(relation, schemaname, relname,
-									   &remstats, remattrmap, attrcnt);
+	{
+		MemoryContext oldcontext = CurrentMemoryContext;
+		ResourceOwner oldowner = CurrentResourceOwner;
+
+		/*
+		 * Import the fetched statistics atomically.  An attribute conversion
+		 * failure is reported by returning false, after possibly updating
+		 * that attribute and any preceding attributes.  Roll those changes
+		 * back so they cannot leak into the sampling fallback.
+		 */
+		BeginInternalSubTransaction(NULL);
+		MemoryContextSwitchTo(oldcontext);
+
+		PG_TRY();
+		{
+			ok = import_fetched_statistics(relation, schemaname, relname,
+										   &remstats, remattrmap, attrcnt);
+
+			if (ok)
+				ReleaseCurrentSubTransaction();
+			else
+				RollbackAndReleaseCurrentSubTransaction();
+			MemoryContextSwitchTo(oldcontext);
+			CurrentResourceOwner = oldowner;
+		}
+		PG_CATCH();
+		{
+			ErrorData  *edata;
+
+			MemoryContextSwitchTo(oldcontext);
+			edata = CopyErrorData();
+			FlushErrorState();
+			RollbackAndReleaseCurrentSubTransaction();
+			MemoryContextSwitchTo(oldcontext);
+			CurrentResourceOwner = oldowner;
+			ReThrowError(edata);
+		}
+		PG_END_TRY();
+	}
 
 	if (ok)
 	{
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 3f6ea7b13..d7652b1cf 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4740,6 +4740,33 @@ SELECT attname, inherited, null_frac, avg_width, n_distinct,
 FROM pg_stats
 WHERE schemaname = 'public' AND tablename = 'dtest_ftable';
 
+-- A failed import must not leave partial attribute statistics behind for an
+-- empty sampling fallback.
+CREATE TABLE simport_atomicity_table (a int, b text);
+CREATE FOREIGN TABLE simport_atomicity_ftable (a int, b int)
+       SERVER loopback OPTIONS (table_name 'simport_atomicity_table',
+                                import_stats 'true');
+INSERT INTO simport_atomicity_table
+SELECT CASE WHEN i <= 9 THEN 11 ELSE 12 END,
+       CASE WHEN i <= 8 THEN '21' ELSE '22' END
+FROM generate_series(1, 10) i;
+ANALYZE simport_atomicity_table;
+ANALYZE simport_atomicity_ftable;
+
+TRUNCATE simport_atomicity_table;
+INSERT INTO simport_atomicity_table
+SELECT CASE WHEN i <= 7 THEN 111 ELSE 112 END,
+       CASE WHEN i <= 6 THEN 'not-an-integer-x' ELSE 'not-an-integer-y' END
+FROM generate_series(1, 10) i;
+ANALYZE simport_atomicity_table;
+DELETE FROM simport_atomicity_table;
+ANALYZE simport_atomicity_ftable;
+
+SELECT attname, most_common_vals::text
+FROM pg_stats
+WHERE schemaname = 'public' AND tablename = 'simport_atomicity_ftable'
+ORDER BY attname;
+
 -- cleanup
 DROP FOREIGN TABLE simport_ftable;
 DROP FOREIGN TABLE simport_fview;
@@ -4749,6 +4776,8 @@ DROP FOREIGN TABLE simport_fpt;
 DROP TABLE simport_pt;
 DROP FOREIGN TABLE dtest_ftable;
 DROP TABLE dtest_table;
+DROP FOREIGN TABLE simport_atomicity_ftable;
+DROP TABLE simport_atomicity_table;
 
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
-- 
2.50.1 (Apple Git-155)

