public inbox for [email protected]  
help / color / mirror / Atom feed
From: Andrew Dunstan <[email protected]>
To: Dean Rasheed <[email protected]>
To: PostgreSQL Hackers <[email protected]>
Subject: Re: Global temporary tables
Date: Sun, 21 Jun 2026 18:05:55 -0400
Message-ID: <[email protected]> (raw)
In-Reply-To: <CAEZATCUbTj5MraZsimV8VYd1M3zrhP8soVJRZ+WvYyea6moefg@mail.gmail.com>
References: <CAEZATCUbTj5MraZsimV8VYd1M3zrhP8soVJRZ+WvYyea6moefg@mail.gmail.com>


On 2026-06-21 Su 3:08 PM, Dean Rasheed wrote:
> I have been thinking about global temporary tables (that is, temporary
> tables whose definition is permanent, and visible to all sessions, but
> whose data is temporary, and local to each session), and I have a
> rough patch set implementing this.
>
> I didn't look closely at the previous patch attempting this, because
> it is quite old, and as others noted, it had significant design
> issues. So I have attempted to come up with a new design, which I have
> split into a series of patches, each focusing on a different aspect of
> the problem, which hopefully makes it easier to think about.
>
>
> 0001 is the basic patch allowing the syntax CREATE GLOBAL TEMP TABLE
> to create a global temporary table with a new relpersistence of
> RELPERSISTENCE_GLOBAL_TEMP. Such tables are only allowed in
> non-temporary schemas.
>
> The majority of the code in this patch is establishing the basic
> infrastructure needed to manage these tables -- the first time a
> global temporary table is used in a session, it is initialised, which
> includes creating local storage for it, using local buffers, just like
> a local temporary table. All global temporary tables in use, and all
> storage created for them, is tracked through operations like
> (sub)transaction rollback, TRUNCATE, etc. and any storage remaining at
> backend exit is deleted.
>
> In the event of a backend crash, there is pre-existing code in
> RemovePgTempFiles() that will delete any temporary files left behind,
> if remove_temp_files_after_crash is on, but if that doesn't happen,
> the new initialisation code will automatically delete any existing
> storage for a relation before creating new storage.
>
> A shared hash table is used to track global temporary tables in use
> across all backends. This is used to prevent operations like ALTER
> TABLE from altering a table that is being used by some other backend,
> if the change would require a rewrite or scan of the table's contents,
> which isn't possible because one backend cannot access the local
> buffers of another.
>
> There's a large header comment in global_temp.c that explains the
> design in more detail.
>
>
> 0002 adds support for indexes. The first time an index on a global
> temporary table is opened in a session other than the session that
> created it, an empty index is built in local storage using
> ambuildempty() (which the patch modifies to accept a fork number
> argument). If the table is not empty (the session had already added
> some data to the table before another session defined the index), then
> the index is marked invalid (more on that in 0009), and cannot be used
> in that session without doing a REINDEX.
>
>
> 0003 adds support for sequences.
>
>
> 0004 allows system catalog tables to be global temporary tables, and
> defines the first such example: pg_temp_class. The idea is that
> pg_temp_class has a subset of the columns of pg_class, allowing those
> properties to override the values from pg_class, allowing global
> temporary tables to operate independently in each session.
>
> In this commit, the only columns are oid, relfilenode, and
> reltablespace, allowing each session to independently track the
> location of the storage of global temporary tables. If a session
> executes CLUSTER, REINDEX, REPACK, TRUNCATE, or VACUUM FULL on a
> global temporary table, the updates are saved to pg_temp_class instead
> of pg_class, so they only affect that session.
>
> ALTER TABLE ... SET TABLESPACE works a little differently in that it
> updates reltablespace in both pg_temp_class and pg_class. This way,
> the change applies to the current session and any future sessions that
> use the table, but not to any other existing sessions that are already
> using it, which continue to use their own pg_temp_class.reltablespace
> values.
>
> There's another large header comment in pg_temp_class.h, explaining
> the design in more detail.
>
> (BTW, I intentionally chose the name pg_temp_class, rather than
> something like pg_global_temp_class, because I think perhaps this, and
> other similar catalog tables might possibly be used in the future for
> local temporary tables too, though I have not explored that idea in
> any detail.)
>
>
> 0005 adds relation statistics columns to pg_temp_class (relpages,
> reltuples, relallvisible, and relallfrozen), and adjusts ANALYZE,
> CREATE INDEX, REPACK, VACUUM, and pg_clear/restore_relation_stats() to
> update pg_temp_class instead of pg_class, for global temporary tables,
> so each session gets its own relation-level statistics.
>
>
> 0006 adds the VACUUM-related fields relfrozenxid and relminmxid to
> pg_temp_class. This is not quite so straightforward though. In
> addition to those fields, I added new fields tempfrozenxid and
> tempminmxid to the PGPROC structure. These are set by each session to
> the minimum values of relfrozenxid and relminmxid over all global
> temporary tables in use by that session. Then, when VACUUM is run, it
> sets pg_temp_class.relfrozenxid/relminmxid, based on the local
> contents of the table, and pg_class.relfrozenxid/relminmxid taking
> into account the contents of other sessions using global temporary
> tables. It's a little crude, because it can't see other
> relfrozenxid/relminmxid values on a per-table level for other
> sessions, but this is sufficient to allow
> pg_database.datfrozenxid/datminmxid to be advanced, provided that each
> session runs VACUUM from time to time.
>
> This still suffers from the same problem as local temporary tables
> though -- if a session uses a local or global temporary table, and
> then just sits there, without ever running VACUUM, there is no way to
> advance the pg_database fields, and eventually there will be a XID
> wraparound danger. Autovacuum doesn't help, because it can't vacuum
> temporary tables. It's not at all clear what can be done about that.
> It might be of some help to add a diagnostic function to identify the
> offending backend, though I have not done so here.
>
>
> 0007 adds another global temporary system catalog table:
> pg_temp_statistic. This has the exact same set of columns as
> pg_statistic, but it is used to hold statistics about global temporary
> tables (and again, it could in theory also be used for local temporary
> tables).
>
> ANALYZE writes to pg_temp_statistic instead of pg_statistic for global
> temporary tables, and various selectivity estimating functions are
> updated to read from it, so each session gets its own local set of
> per-column statistics for the global temporary tables that it uses.
>
> The pg_stats view is updated to a UNION ALL query selecting from
> pg_statistic and pg_temp_statistic, so users can view their own
> statistics data in the usual way.
>
>
> 0008 adds pg_temp_statistic_ext_data, which is exactly the same as
> pg_statistic_ext_data, except that it is a global temporary table used
> to store extended statistics data for global temporary tables. The
> views pg_stats_ext and pg_stats_ext_exprs are updated to include this.
>
>
> 0009 adds a final global temporary system catalog table:
> pg_temp_index. As noted in 0002, a session needs to be able to mark an
> index on a global temporary table as invalid locally, if it was added
> by another session after this session had already populated the table.
> So pg_temp_index has indexrelid and indisvalid columns, so that the
> valid state of an index can be overridden locally.
>
>
> So far, I've focused on getting a set of patches that work, and these
> do seem to operate as expected. However, it seems quite likely that
> there are things that I have overlooked.
>
> I'm also aware that I haven't written any documentation yet, and I
> need to add more tests, but as a rough set of patches, I hope that
> they're in good enough shape for review.
>

Wow, we're on the same track. I have a patch series for exactly this 
feature that I was about to submit.

FTR here's where I'm at. I'll try to take a look at yours ASAP.



cheers


andrew



--
Andrew Dunstan
EDB: https://www.enterprisedb.com


Attachments:

  [text/x-patch] 0001-Global-temporary-tables-catalog-and-DDL-support.patch (39.7K, ../[email protected]/2-0001-Global-temporary-tables-catalog-and-DDL-support.patch)
  download | inline diff:
From 121048ebe85349f273a6f20da043596dad1f7287 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Fri, 13 Mar 2026 10:22:37 -0400
Subject: [PATCH 01/12] Global temporary tables: catalog and DDL support

Add a new relpersistence value RELPERSISTENCE_GLOBAL_TEMP ('g') for
global temporary tables.  Per the SQL standard, global temporary tables
have a persistent, shared catalog definition visible to all sessions,
but per-session private data (the per-session data isolation is not yet
implemented and will come in a subsequent commit).

The grammar now produces RELPERSISTENCE_GLOBAL_TEMP for CREATE GLOBAL
TEMPORARY TABLE, replacing the previous deprecation warning.  ON COMMIT
PRESERVE ROWS and DELETE ROWS are accepted; ON COMMIT DROP is rejected
since the table definition is persistent.

All relpersistence switch statements across the backend are updated to
handle the new value: catalog.c, storage.c, relcache.c, bufmgr.c,
dbsize.c, and tablecmds.c.  The new persistence type uses shared
buffers and no WAL, similar to unlogged tables; a subsequent commit
redirects it to local buffers.

Sequences implicitly created for SERIAL or IDENTITY columns of a GTT
inherit the GTT persistence, so each session gets its own counter.
parse_utilcmd propagates RELPERSISTENCE_GLOBAL_TEMP to the generated
CreateSeqStmt, and relation_open seeds the per-session sequence page
on first open from pg_sequence's initial state, so even a direct scan
of the sequence relation (SELECT last_value FROM seq, as psql's \d
emits) sees the one mandatory row in any session.  Standalone CREATE
GLOBAL TEMPORARY SEQUENCE is also supported.

GetNewRelFileNumber probes the backend's temp-relation namespace for
RELPERSISTENCE_GLOBAL_TEMP, since that is where a GTT's per-session
files actually live; the shared path never holds a file for a GTT.

psql \dt+ shows "global temporary" in the Persistence column.
pg_dump emits CREATE GLOBAL TEMPORARY TABLE.

Restrictions enforced:
- FK constraints between GTTs and non-GTTs are rejected
- ALTER TABLE SET LOGGED/UNLOGGED is rejected for GTTs
- ALTER TABLE SET TABLESPACE is rejected for GTTs (would rotate the
  shared-catalog relfilenode that every session uses as the stem of
  its own per-session file name)
- Mixing GTTs and local temp tables in inheritance is rejected, both
  at CREATE TABLE time (MergeAttributes) and at ALTER TABLE INHERIT /
  ATTACH PARTITION time
- GTTs cannot be created in temporary schemas
- CREATE GLOBAL TEMPORARY VIEW and CREATE GLOBAL TEMPORARY PROPERTY
  GRAPH are rejected: such relations have no storage for the GTT
  machinery to manage

- CREATE MATERIALIZED VIEW over a GTT is rejected: the matview's heap is
  permanent and shared, so populating it would capture one session's
  private rows.  Direct references are caught at creation time via
  query_uses_temp_object (which gains a global_temp_matters flag, false
  for the view-downgrade and function-lifetime callers); references
  through a view are caught at population time, where the planner has
  flattened the view into the range table -- covering both CREATE ...
  WITH DATA and REFRESH.  Plain views over GTTs remain allowed and stay
  permanent: they re-evaluate per session.
---
 src/backend/access/common/relation.c |  24 +++++
 src/backend/catalog/catalog.c        |  13 +++
 src/backend/catalog/dependency.c     |  28 +++++-
 src/backend/catalog/namespace.c      |  15 +++
 src/backend/catalog/pg_proc.c        |   3 +-
 src/backend/catalog/storage.c        |   1 +
 src/backend/commands/matview.c       |  21 ++++
 src/backend/commands/propgraphcmds.c |   6 ++
 src/backend/commands/sequence.c      |  56 ++++++++++-
 src/backend/commands/tablecmds.c     | 142 +++++++++++++++++++++++++--
 src/backend/commands/view.c          |  12 ++-
 src/backend/parser/analyze.c         |  27 +++--
 src/backend/parser/gram.y            |  36 ++-----
 src/backend/storage/buffer/bufmgr.c  |   3 +-
 src/backend/utils/adt/dbsize.c       |   1 +
 src/backend/utils/cache/relcache.c   |   2 +
 src/bin/pg_dump/pg_dump.c            |   2 +
 src/bin/psql/describe.c              |   2 +
 src/include/catalog/dependency.h     |   4 +-
 src/include/catalog/pg_class.h       |   1 +
 src/include/commands/sequence.h      |   2 +
 src/include/utils/rel.h              |  11 +++
 22 files changed, 361 insertions(+), 51 deletions(-)

diff --git a/src/backend/access/common/relation.c b/src/backend/access/common/relation.c
index 38b356b8239..57eca0ee635 100644
--- a/src/backend/access/common/relation.c
+++ b/src/backend/access/common/relation.c
@@ -23,12 +23,30 @@
 #include "access/relation.h"
 #include "access/xact.h"
 #include "catalog/namespace.h"
+#include "commands/sequence.h"
 #include "pgstat.h"
 #include "storage/lmgr.h"
 #include "storage/lock.h"
 #include "utils/inval.h"
 #include "utils/syscache.h"
 
+static void relation_open_gtt_prepare(Relation r);
+
+/*
+ * relation_open_gtt_prepare
+ *		Lazily materialize the session-local pieces of a global temporary
+ *		relation that need more than bare storage: sequences must be seeded
+ *		with their initial tuple.  Doing this here, at the single chokepoint
+ *		every open funnels through, covers direct relation_open callers
+ *		(executor scans of a sequence) as well as the sequence functions.
+ */
+static void
+relation_open_gtt_prepare(Relation r)
+{
+	if (r->rd_rel->relkind == RELKIND_SEQUENCE)
+		GttEnsureSequenceInitialized(r);
+}
+
 
 /* ----------------
  *		relation_open - open any relation by relation OID
@@ -73,6 +91,9 @@ relation_open(Oid relationId, LOCKMODE lockmode)
 	if (RelationUsesLocalBuffers(r))
 		MyXactFlags |= XACT_FLAGS_ACCESSEDTEMPNAMESPACE;
 
+	if (RelationIsGlobalTemp(r))
+		relation_open_gtt_prepare(r);
+
 	pgstat_init_relation(r);
 
 	return r;
@@ -123,6 +144,9 @@ try_relation_open(Oid relationId, LOCKMODE lockmode)
 	if (RelationUsesLocalBuffers(r))
 		MyXactFlags |= XACT_FLAGS_ACCESSEDTEMPNAMESPACE;
 
+	if (RelationIsGlobalTemp(r))
+		relation_open_gtt_prepare(r);
+
 	pgstat_init_relation(r);
 
 	return r;
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..48fb623b2e8 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -573,6 +573,19 @@ GetNewRelFileNumber(Oid reltablespace, Relation pg_class, char relpersistence)
 		case RELPERSISTENCE_TEMP:
 			procNumber = ProcNumberForTempRelations();
 			break;
+
+			/*
+			 * A global temporary table never has a file at the shared path;
+			 * its per-session files live in each backend's temp-relation
+			 * namespace, using this relfilenumber.  Probe our own temp
+			 * namespace, the same one the file will be created in for this
+			 * session; other backends' namespaces cannot be checked here,
+			 * which is the same (wraparound-only) exposure regular temp
+			 * tables have.
+			 */
+		case RELPERSISTENCE_GLOBAL_TEMP:
+			procNumber = ProcNumberForTempRelations();
+			break;
 		case RELPERSISTENCE_UNLOGGED:
 		case RELPERSISTENCE_PERMANENT:
 			procNumber = INVALID_PROC_NUMBER;
diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c
index c54774b3275..fd4cc08c724 100644
--- a/src/backend/catalog/dependency.c
+++ b/src/backend/catalog/dependency.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_amproc.h"
 #include "catalog/pg_attrdef.h"
 #include "catalog/pg_authid.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_auth_members.h"
 #include "catalog/pg_cast.h"
 #include "catalog/pg_collation.h"
@@ -2533,6 +2534,14 @@ process_function_rte_ref(RangeTblEntry *rte, AttrNumber attnum,
  * local_temp_okay is true.  If one is found, return true after storing its
  * address in *foundobj.
  *
+ * If include_gtt is true, relations that are global temporary
+ * tables also count as temporary objects.  GTTs live in ordinary schemas,
+ * so the namespace test never catches them; but their *data* is
+ * session-private, which is what callers like the materialized-view check
+ * care about.  Callers that only care about object lifetime (e.g. the
+ * temporary-view downgrade, where the GTT definition's persistence is what
+ * matters) pass false.
+ *
  * Current callers only use this to deliver helpful notices, so reporting
  * one such object seems sufficient.  We return the first one, which should
  * be a stable result for a given query since it depends only on the order
@@ -2542,13 +2551,22 @@ process_function_rte_ref(RangeTblEntry *rte, AttrNumber attnum,
  */
 bool
 find_temp_object(const ObjectAddresses *addrs, bool local_temp_okay,
-				 ObjectAddress *foundobj)
+				 bool include_gtt, ObjectAddress *foundobj)
 {
 	for (int i = 0; i < addrs->numrefs; i++)
 	{
 		const ObjectAddress *thisobj = addrs->refs + i;
 		Oid			objnamespace;
 
+		/* Global temporary tables hold session-private data. */
+		if (include_gtt &&
+			thisobj->classId == RelationRelationId &&
+			get_rel_persistence(thisobj->objectId) == RELPERSISTENCE_GLOBAL_TEMP)
+		{
+			*foundobj = *thisobj;
+			return true;
+		}
+
 		/*
 		 * Use get_object_namespace() to see if this object belongs to a
 		 * schema.  If not, we can skip it.
@@ -2573,10 +2591,12 @@ find_temp_object(const ObjectAddresses *addrs, bool local_temp_okay,
  * query_uses_temp_object - convenience wrapper for find_temp_object
  *
  * If the Query includes any use of a temporary object, fill *temp_object
- * with the address of one such object and return true.
+ * with the address of one such object and return true.  See
+ * find_temp_object for the meaning of include_gtt.
  */
 bool
-query_uses_temp_object(Query *query, ObjectAddress *temp_object)
+query_uses_temp_object(Query *query, bool include_gtt,
+					   ObjectAddress *temp_object)
 {
 	bool		result;
 	ObjectAddresses *addrs;
@@ -2587,7 +2607,7 @@ query_uses_temp_object(Query *query, ObjectAddress *temp_object)
 	collectDependenciesOfExpr(addrs, (Node *) query, NIL);
 
 	/* Look for one that is temp */
-	result = find_temp_object(addrs, false, temp_object);
+	result = find_temp_object(addrs, false, include_gtt, temp_object);
 
 	free_object_addresses(addrs);
 
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index 56b87d878e8..e26651099f9 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -869,6 +869,21 @@ RangeVarAdjustRelationPersistence(RangeVar *newRelation, Oid nspid)
 						(errcode(ERRCODE_INVALID_TABLE_DEFINITION),
 						 errmsg("cannot create relations in temporary schemas of other sessions")));
 			break;
+		case RELPERSISTENCE_GLOBAL_TEMP:
+
+			/*
+			 * A global temporary table is a permanent catalog object that
+			 * merely carries per-session data, so it has no business living
+			 * in a temporary schema.  Reject it explicitly rather than
+			 * letting it fall through to the generic message below, which
+			 * would wrongly imply a global temporary table is not a temporary
+			 * relation.
+			 */
+			if (isAnyTempNamespace(nspid))
+				ereport(ERROR,
+						(errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+						 errmsg("cannot create a global temporary table in a temporary schema")));
+			break;
 		default:
 			if (isAnyTempNamespace(nspid))
 				ereport(ERROR,
diff --git a/src/backend/catalog/pg_proc.c b/src/backend/catalog/pg_proc.c
index 5df4b3f7a91..fa4b140bef6 100644
--- a/src/backend/catalog/pg_proc.c
+++ b/src/backend/catalog/pg_proc.c
@@ -676,7 +676,8 @@ ProcedureCreate(const char *procedureName,
 	 * function created in our own pg_temp namespace refers to other objects
 	 * in that namespace, since then they'll have similar lifespans anyway.
 	 */
-	if (find_temp_object(addrs, isTempNamespace(procNamespace), &temp_object))
+	if (find_temp_object(addrs, isTempNamespace(procNamespace), false,
+						 &temp_object))
 		ereport(NOTICE,
 				(errmsg("function \"%s\" will be effectively temporary",
 						procedureName),
diff --git a/src/backend/catalog/storage.c b/src/backend/catalog/storage.c
index e443a4993c5..febd8c246ca 100644
--- a/src/backend/catalog/storage.c
+++ b/src/backend/catalog/storage.c
@@ -135,6 +135,7 @@ RelationCreateStorage(RelFileLocator rlocator, char relpersistence,
 			needs_wal = false;
 			break;
 		case RELPERSISTENCE_UNLOGGED:
+		case RELPERSISTENCE_GLOBAL_TEMP:
 			procNumber = INVALID_PROC_NUMBER;
 			needs_wal = false;
 			break;
diff --git a/src/backend/commands/matview.c b/src/backend/commands/matview.c
index f7d8007f796..36ebc95ea60 100644
--- a/src/backend/commands/matview.c
+++ b/src/backend/commands/matview.c
@@ -427,6 +427,27 @@ refresh_matview_datafill(DestReceiver *dest, Query *query,
 	/* Plan the query which will generate data for the refresh. */
 	plan = pg_plan_query(query, queryString, CURSOR_OPT_PARALLEL_OK, NULL, NULL);
 
+	/*
+	 * A materialized view must not read a global temporary table: GTT
+	 * contents are session-private, while the matview's heap is permanent and
+	 * shared, so populating it would capture -- and publish -- one session's
+	 * private rows.  Direct references are rejected at creation time (see
+	 * transformCreateTableAsStmt), but a GTT reached through a view only
+	 * appears once the planner has flattened the view into the range table,
+	 * so check the planned rtable here.  This covers both CREATE MATERIALIZED
+	 * VIEW ... WITH DATA and REFRESH.
+	 */
+	foreach_node(RangeTblEntry, rte, plan->rtable)
+	{
+		if (rte->rtekind == RTE_RELATION &&
+			get_rel_persistence(rte->relid) == RELPERSISTENCE_GLOBAL_TEMP)
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("materialized views must not use temporary objects"),
+					 errdetail("This view depends on global temporary table \"%s\".",
+							   get_rel_name(rte->relid))));
+	}
+
 	/*
 	 * Use a snapshot with an updated command ID to ensure this query sees
 	 * results of any previously executed queries.  (This could only matter if
diff --git a/src/backend/commands/propgraphcmds.c b/src/backend/commands/propgraphcmds.c
index cc516e27020..28ed1338f60 100644
--- a/src/backend/commands/propgraphcmds.c
+++ b/src/backend/commands/propgraphcmds.c
@@ -117,6 +117,12 @@ CreatePropGraph(ParseState *pstate, const CreatePropGraphStmt *stmt)
 				(errcode(ERRCODE_SYNTAX_ERROR),
 				 errmsg("property graphs cannot be unlogged because they do not have storage")));
 
+	/* same for global temporary, see DefineView */
+	if (stmt->pgname->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(ERROR,
+				(errcode(ERRCODE_SYNTAX_ERROR),
+				 errmsg("property graphs cannot be global temporary because they do not have storage")));
+
 	components_persistence = RELPERSISTENCE_PERMANENT;
 
 	foreach(lc, stmt->vertex_tables)
diff --git a/src/backend/commands/sequence.c b/src/backend/commands/sequence.c
index 551667650ba..8eef726a228 100644
--- a/src/backend/commands/sequence.c
+++ b/src/backend/commands/sequence.c
@@ -420,6 +420,56 @@ fill_seq_fork_with_data(Relation rel, HeapTuple tuple, ForkNumber forkNum)
 	UnlockReleaseBuffer(buf);
 }
 
+/*
+ * GttEnsureSequenceInitialized
+ *		Seed per-session storage for a global temporary sequence.
+ *
+ * A GTT sequence's catalog row is shared across sessions, but each session
+ * has its own physical storage (allocated lazily by GttInitSessionStorage).
+ * That storage starts out empty — block 0 does not exist — so any attempt
+ * to read the sequence tuple would fail.  Called from relation_open for
+ * every GTT sequence, so even a direct heapscan of the sequence (SELECT
+ * last_value FROM seq, as psql's \d emits) sees the one mandatory row.
+ * The first open in each session writes block 0, using the definition
+ * recorded in pg_sequence as the initial state (last_value = seqstart,
+ * log_cnt = 0, is_called = false).
+ */
+void
+GttEnsureSequenceInitialized(Relation rel)
+{
+	HeapTuple	pgstuple;
+	Form_pg_sequence pgsform;
+	Datum		value[SEQ_COL_LASTCOL];
+	bool		null[SEQ_COL_LASTCOL] = {0};
+	HeapTuple	tuple;
+
+	if (rel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
+		return;
+
+	if (RelationGetNumberOfBlocks(rel) > 0)
+		return;
+
+	/*
+	 * No pg_sequence row yet: we are inside CREATE SEQUENCE, opened from
+	 * DefineSequence before the row is inserted.  The creator fills the
+	 * initial tuple itself via fill_seq_with_data.
+	 */
+	pgstuple = SearchSysCache1(SEQRELID, ObjectIdGetDatum(RelationGetRelid(rel)));
+	if (!HeapTupleIsValid(pgstuple))
+		return;
+	pgsform = (Form_pg_sequence) GETSTRUCT(pgstuple);
+
+	value[SEQ_COL_LASTVAL - 1] = Int64GetDatumFast(pgsform->seqstart);
+	value[SEQ_COL_LOG - 1] = Int64GetDatum((int64) 0);
+	value[SEQ_COL_CALLED - 1] = BoolGetDatum(false);
+
+	ReleaseSysCache(pgstuple);
+
+	tuple = heap_form_tuple(RelationGetDescr(rel), value, null);
+	fill_seq_fork_with_data(rel, tuple, MAIN_FORKNUM);
+	heap_freetuple(tuple);
+}
+
 /*
  * AlterSequence
  *
@@ -1815,12 +1865,14 @@ pg_get_sequence_data(PG_FUNCTION_ARGS)
 
 	/*
 	 * Return all NULLs for missing sequences, sequences for which we lack
-	 * privileges, other sessions' temporary sequences, and unlogged sequences
-	 * on standbys.
+	 * privileges, other sessions' temporary sequences, global temporary
+	 * sequences (their data is per-session and not meaningful across backends
+	 * or in a dump), and unlogged sequences on standbys.
 	 */
 	if (seqrel && seqrel->rd_rel->relkind == RELKIND_SEQUENCE &&
 		pg_class_aclcheck(relid, GetUserId(), ACL_SELECT) == ACLCHECK_OK &&
 		!RELATION_IS_OTHER_TEMP(seqrel) &&
+		seqrel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP &&
 		(RelationIsPermanent(seqrel) || !RecoveryInProgress()))
 	{
 		Buffer		buf;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 265dcfe7fda..82700c49ba0 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -843,11 +843,18 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	 * Check consistency of arguments
 	 */
 	if (stmt->oncommit != ONCOMMIT_NOOP
-		&& stmt->relation->relpersistence != RELPERSISTENCE_TEMP)
+		&& stmt->relation->relpersistence != RELPERSISTENCE_TEMP
+		&& stmt->relation->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_TABLE_DEFINITION),
 				 errmsg("ON COMMIT can only be used on temporary tables")));
 
+	if (stmt->oncommit == ONCOMMIT_DROP
+		&& stmt->relation->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+				errmsg("ON COMMIT DROP is not supported for global temporary tables"));
+
 	if (stmt->partspec != NULL)
 	{
 		if (relkind != RELKIND_RELATION)
@@ -1095,6 +1102,23 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 			accessMethodId = get_table_am_oid(default_table_access_method, false);
 	}
 
+	/*
+	 * Global temporary tables rely on the heap table access method.  Their
+	 * per-session storage, local buffering, and tuple visibility handling are
+	 * all heap-specific (see storage_gtt.c), and the wraparound-safety
+	 * reasoning for GTTs assumes heap.  Reject any other access method --
+	 * whether requested with USING or inherited from
+	 * default_table_access_method -- rather than create a table that cannot
+	 * work correctly.
+	 */
+	if (stmt->relation->relpersistence == RELPERSISTENCE_GLOBAL_TEMP &&
+		OidIsValid(accessMethodId) &&
+		accessMethodId != HEAP_TABLE_AM_OID)
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("access method \"%s\" is not supported for global temporary tables",
+					   get_am_name(accessMethodId)));
+
 	/*
 	 * Create the relation.  Inherited defaults and CHECK constraints are
 	 * passed in for immediate handling --- since they don't need parsing,
@@ -2762,7 +2786,9 @@ MergeAttributes(List *columns, const List *supers, char relpersistence,
 		 */
 		if (is_partition &&
 			relation->rd_rel->relpersistence != RELPERSISTENCE_TEMP &&
-			relpersistence == RELPERSISTENCE_TEMP)
+			relation->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP &&
+			(relpersistence == RELPERSISTENCE_TEMP ||
+			 relpersistence == RELPERSISTENCE_GLOBAL_TEMP))
 			ereport(ERROR,
 					(errcode(ERRCODE_WRONG_OBJECT_TYPE),
 					 errmsg("cannot create a temporary relation as partition of permanent relation \"%s\"",
@@ -2770,7 +2796,9 @@ MergeAttributes(List *columns, const List *supers, char relpersistence,
 
 		/* Permanent rels cannot inherit from temporary ones */
 		if (relpersistence != RELPERSISTENCE_TEMP &&
-			relation->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
+			relpersistence != RELPERSISTENCE_GLOBAL_TEMP &&
+			(relation->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
+			 RelationIsGlobalTemp(relation)))
 			ereport(ERROR,
 					(errcode(ERRCODE_WRONG_OBJECT_TYPE),
 					 errmsg(!is_partition
@@ -2778,6 +2806,19 @@ MergeAttributes(List *columns, const List *supers, char relpersistence,
 							: "cannot create a permanent relation as partition of temporary relation \"%s\"",
 							RelationGetRelationName(relation))));
 
+		/*
+		 * Don't allow mixing global temporary tables with local temporary
+		 * tables in inheritance or partitioning hierarchies, in either
+		 * direction.
+		 */
+		if ((relpersistence == RELPERSISTENCE_GLOBAL_TEMP &&
+			 relation->rd_rel->relpersistence == RELPERSISTENCE_TEMP) ||
+			(relpersistence == RELPERSISTENCE_TEMP &&
+			 RelationIsGlobalTemp(relation)))
+			ereport(ERROR,
+					errcode(ERRCODE_WRONG_OBJECT_TYPE),
+					errmsg("cannot mix global temporary and local temporary tables in inheritance"));
+
 		/* If existing rel is temp, it must belong to this session */
 		if (RELATION_IS_OTHER_TEMP(relation))
 			ereport(ERROR,
@@ -6005,6 +6046,21 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
 						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 						 errmsg("cannot rewrite temporary tables of other sessions")));
 
+			/*
+			 * A table rewrite rotates the catalog relfilenode.  For a GTT,
+			 * every session's per-session storage is keyed by the catalog
+			 * relfilenode, so rotating it would leave other sessions pointing
+			 * at files that no longer exist or at the wrong generation of the
+			 * table.  Block rewrites for GTTs along with the other
+			 * relfilenode-rotating commands (CLUSTER / REINDEX / SET
+			 * TABLESPACE / SET LOGGED|UNLOGGED).
+			 */
+			if (RelationIsGlobalTemp(OldHeap))
+				ereport(ERROR,
+						errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						errmsg("cannot rewrite global temporary table \"%s\"",
+							   RelationGetRelationName(OldHeap)));
+
 			/*
 			 * Select destination tablespace (same as original unless user
 			 * requested a change)
@@ -10235,6 +10291,12 @@ ATAddForeignKeyConstraint(List **wqueue, AlteredTableInfo *tab, Relation rel,
 						(errcode(ERRCODE_INVALID_TABLE_DEFINITION),
 						 errmsg("constraints on temporary tables must involve temporary tables of this session")));
 			break;
+		case RELPERSISTENCE_GLOBAL_TEMP:
+			if (pkrel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+						errmsg("constraints on global temporary tables may reference only global temporary tables"));
+			break;
 	}
 
 	/*
@@ -16873,6 +16935,18 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 {
 	Oid			tablespaceId;
 
+	/*
+	 * SET TABLESPACE rewrites the file and assigns a new relfilenode.  For
+	 * GTTs the catalog relfilenode is the stem of every session's local file
+	 * name, so rotating it would desynchronize all other sessions'
+	 * per-session storage mappings.
+	 */
+	if (RelationIsGlobalTemp(rel))
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("cannot change tablespace of global temporary table \"%s\"",
+					   RelationGetRelationName(rel)));
+
 	/* Check that the tablespace exists */
 	tablespaceId = get_tablespace_oid(tablespacename, false);
 
@@ -17538,6 +17612,23 @@ ATExecAddInherit(Relation child_rel, RangeVar *parent, LOCKMODE lockmode)
 				 errmsg("cannot inherit from temporary relation \"%s\"",
 						RelationGetRelationName(parent_rel))));
 
+	/*
+	 * GTTs mix neither with permanent nor with local temporary relations. See
+	 * MergeAttributes() for the CREATE TABLE side of this rule.
+	 */
+	if (RelationIsGlobalTemp(parent_rel) &&
+		child_rel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("cannot inherit from global temporary relation \"%s\"",
+					   RelationGetRelationName(parent_rel)));
+	if (parent_rel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP &&
+		RelationIsGlobalTemp(child_rel))
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("cannot inherit into global temporary relation \"%s\"",
+					   RelationGetRelationName(child_rel)));
+
 	/* If parent rel is temp, it must belong to this session */
 	if (RELATION_IS_OTHER_TEMP(parent_rel))
 		ereport(ERROR,
@@ -19087,6 +19178,13 @@ ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel, bool toLogged)
 							RelationGetRelationName(rel)),
 					 errtable(rel)));
 			break;
+		case RELPERSISTENCE_GLOBAL_TEMP:
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+					errmsg("cannot change logged status of table \"%s\" because it is a global temporary table",
+						   RelationGetRelationName(rel)),
+					errtable(rel));
+			break;
 		case RELPERSISTENCE_PERMANENT:
 			if (toLogged)
 				/* nothing to do */
@@ -20686,20 +20784,38 @@ ATExecAttachPartition(List **wqueue, Relation rel, PartitionCmd *cmd,
 
 	/* If the parent is permanent, so must be all of its partitions. */
 	if (rel->rd_rel->relpersistence != RELPERSISTENCE_TEMP &&
-		attachrel->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
+		rel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP &&
+		(attachrel->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
+		 RelationIsGlobalTemp(attachrel)))
 		ereport(ERROR,
 				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
 				 errmsg("cannot attach a temporary relation as partition of permanent relation \"%s\"",
 						RelationGetRelationName(rel))));
 
 	/* Temp parent cannot have a partition that is itself not a temp */
-	if (rel->rd_rel->relpersistence == RELPERSISTENCE_TEMP &&
-		attachrel->rd_rel->relpersistence != RELPERSISTENCE_TEMP)
+	if ((rel->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
+		 RelationIsGlobalTemp(rel)) &&
+		attachrel->rd_rel->relpersistence != RELPERSISTENCE_TEMP &&
+		attachrel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
 		ereport(ERROR,
 				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
 				 errmsg("cannot attach a permanent relation as partition of temporary relation \"%s\"",
 						RelationGetRelationName(rel))));
 
+	/* GTT and local-temp cannot mix as partition parent/child */
+	if (RelationIsGlobalTemp(rel) &&
+		attachrel->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("cannot attach a local temporary relation as partition of global temporary relation \"%s\"",
+					   RelationGetRelationName(rel)));
+	if (rel->rd_rel->relpersistence == RELPERSISTENCE_TEMP &&
+		RelationIsGlobalTemp(attachrel))
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("cannot attach a global temporary relation as partition of local temporary relation \"%s\"",
+					   RelationGetRelationName(rel)));
+
 	/* If the parent is temp, it must belong to this session */
 	if (RELATION_IS_OTHER_TEMP(rel))
 		ereport(ERROR,
@@ -22786,6 +22902,20 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
 				errmsg("cannot create a permanent relation as partition of temporary relation \"%s\"",
 					   RelationGetRelationName(parent_rel)));
 
+	/*
+	 * Splitting or merging partitions of a global temporary table is not
+	 * supported.  The new partition created here would not inherit the
+	 * parent's global temporary persistence, so it would be given permanent,
+	 * cluster-wide storage underneath a parent whose data is per-session.
+	 * Reject the command rather than silently creating such an inconsistent
+	 * partition (this function is only reached from SPLIT/MERGE PARTITION).
+	 */
+	if (parent_relform->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("cannot split or merge partitions of global temporary table \"%s\"",
+					   RelationGetRelationName(parent_rel)));
+
 	/* Create the relation. */
 	newRelId = heap_create_with_catalog(newPartName->relname,
 										namespaceId,
diff --git a/src/backend/commands/view.c b/src/backend/commands/view.c
index 1bd78a4cdf0..b124ca2b514 100644
--- a/src/backend/commands/view.c
+++ b/src/backend/commands/view.c
@@ -476,6 +476,16 @@ DefineView(ViewStmt *stmt, const char *queryString,
 				(errcode(ERRCODE_SYNTAX_ERROR),
 				 errmsg("views cannot be unlogged because they do not have storage")));
 
+	/*
+	 * Nor are global temporary views: a view has no per-session data for the
+	 * GTT machinery to manage, and downstream code would wrongly treat the
+	 * relation as having session-private storage.
+	 */
+	if (stmt->view->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(ERROR,
+				(errcode(ERRCODE_SYNTAX_ERROR),
+				 errmsg("views cannot be global temporary because they do not have storage")));
+
 	/*
 	 * If the user didn't explicitly ask for a temporary view, check whether
 	 * we need one implicitly.  We allow TEMP to be inserted automatically as
@@ -484,7 +494,7 @@ DefineView(ViewStmt *stmt, const char *queryString,
 	 */
 	view = copyObject(stmt->view);	/* don't corrupt original command */
 	if (view->relpersistence == RELPERSISTENCE_PERMANENT
-		&& query_uses_temp_object(viewParse, &temp_object))
+		&& query_uses_temp_object(viewParse, false, &temp_object))
 	{
 		view->relpersistence = RELPERSISTENCE_TEMP;
 		ereport(NOTICE,
diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c
index 93fa66ae57c..24cc06776d2 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -3536,14 +3536,27 @@ transformCreateTableAsStmt(ParseState *pstate, CreateTableAsStmt *stmt)
 		/*
 		 * Check whether any temporary database objects are used in the
 		 * creation query. It would be hard to refresh data or incrementally
-		 * maintain it if a source disappeared.
+		 * maintain it if a source disappeared.  Global temporary tables
+		 * count: their definition persists, but their contents are
+		 * session-private, so materializing them into a permanent relation
+		 * would capture (and publish) one session's private rows.
 		 */
-		if (query_uses_temp_object(query, &temp_object))
-			ereport(ERROR,
-					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-					 errmsg("materialized views must not use temporary objects"),
-					 errdetail("This view depends on temporary %s.",
-							   getObjectDescription(&temp_object, false))));
+		if (query_uses_temp_object(query, true, &temp_object))
+		{
+			if (temp_object.classId == RelationRelationId &&
+				get_rel_persistence(temp_object.objectId) == RELPERSISTENCE_GLOBAL_TEMP)
+				ereport(ERROR,
+						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						 errmsg("materialized views must not use temporary objects"),
+						 errdetail("This view depends on global temporary table \"%s\".",
+								   get_rel_name(temp_object.objectId))));
+			else
+				ereport(ERROR,
+						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						 errmsg("materialized views must not use temporary objects"),
+						 errdetail("This view depends on temporary %s.",
+								   getObjectDescription(&temp_object, false))));
+		}
 
 		/*
 		 * A materialized view would either need to save parameters for use in
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..44e28b9d588 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -3913,31 +3913,17 @@ CreateStmt:	CREATE OptTemp TABLE qualified_name '(' OptTableElementList ')'
  * Redundancy here is needed to avoid shift/reduce conflicts,
  * since TEMP is not a reserved word.  See also OptTempTableName.
  *
- * NOTE: we accept both GLOBAL and LOCAL options.  They currently do nothing,
- * but future versions might consider GLOBAL to request SQL-spec-compliant
- * temp table behavior, so warn about that.  Since we have no modules the
- * LOCAL keyword is really meaningless; furthermore, some other products
- * implement LOCAL as meaning the same as our default temp table behavior,
- * so we'll probably continue to treat LOCAL as a noise word.
+ * NOTE: GLOBAL creates a SQL-standard global temporary table, visible to
+ * all sessions but with per-session data.  LOCAL is treated as equivalent
+ * to plain TEMPORARY (session-local), as most other products implement it;
+ * since we have no module-level scope, LOCAL remains a noise word.
  */
 OptTemp:	TEMPORARY					{ $$ = RELPERSISTENCE_TEMP; }
 			| TEMP						{ $$ = RELPERSISTENCE_TEMP; }
 			| LOCAL TEMPORARY			{ $$ = RELPERSISTENCE_TEMP; }
 			| LOCAL TEMP				{ $$ = RELPERSISTENCE_TEMP; }
-			| GLOBAL TEMPORARY
-				{
-					ereport(WARNING,
-							(errmsg("GLOBAL is deprecated in temporary table creation"),
-							 parser_errposition(@1)));
-					$$ = RELPERSISTENCE_TEMP;
-				}
-			| GLOBAL TEMP
-				{
-					ereport(WARNING,
-							(errmsg("GLOBAL is deprecated in temporary table creation"),
-							 parser_errposition(@1)));
-					$$ = RELPERSISTENCE_TEMP;
-				}
+			| GLOBAL TEMPORARY			{ $$ = RELPERSISTENCE_GLOBAL_TEMP; }
+			| GLOBAL TEMP				{ $$ = RELPERSISTENCE_GLOBAL_TEMP; }
 			| UNLOGGED					{ $$ = RELPERSISTENCE_UNLOGGED; }
 			| /*EMPTY*/					{ $$ = RELPERSISTENCE_PERMANENT; }
 		;
@@ -13972,19 +13958,13 @@ OptTempTableName:
 				}
 			| GLOBAL TEMPORARY opt_table qualified_name
 				{
-					ereport(WARNING,
-							(errmsg("GLOBAL is deprecated in temporary table creation"),
-							 parser_errposition(@1)));
 					$$ = $4;
-					$$->relpersistence = RELPERSISTENCE_TEMP;
+					$$->relpersistence = RELPERSISTENCE_GLOBAL_TEMP;
 				}
 			| GLOBAL TEMP opt_table qualified_name
 				{
-					ereport(WARNING,
-							(errmsg("GLOBAL is deprecated in temporary table creation"),
-							 parser_errposition(@1)));
 					$$ = $4;
-					$$->relpersistence = RELPERSISTENCE_TEMP;
+					$$->relpersistence = RELPERSISTENCE_GLOBAL_TEMP;
 				}
 			| UNLOGGED opt_table qualified_name
 				{
diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
index d6c0cc1f6d4..cd0bcdf2fe8 100644
--- a/src/backend/storage/buffer/bufmgr.c
+++ b/src/backend/storage/buffer/bufmgr.c
@@ -1237,7 +1237,8 @@ PinBufferForBlock(Relation rel,
 	/* Persistence should be set before */
 	Assert((persistence == RELPERSISTENCE_TEMP ||
 			persistence == RELPERSISTENCE_PERMANENT ||
-			persistence == RELPERSISTENCE_UNLOGGED));
+			persistence == RELPERSISTENCE_UNLOGGED ||
+			persistence == RELPERSISTENCE_GLOBAL_TEMP));
 
 	TRACE_POSTGRESQL_BUFFER_READ_START(forkNum, blockNum,
 									   smgr->smgr_rlocator.locator.spcOid,
diff --git a/src/backend/utils/adt/dbsize.c b/src/backend/utils/adt/dbsize.c
index cccc4a24c84..7243ec0dfd9 100644
--- a/src/backend/utils/adt/dbsize.c
+++ b/src/backend/utils/adt/dbsize.c
@@ -1020,6 +1020,7 @@ pg_relation_filepath(PG_FUNCTION_ARGS)
 	{
 		case RELPERSISTENCE_UNLOGGED:
 		case RELPERSISTENCE_PERMANENT:
+		case RELPERSISTENCE_GLOBAL_TEMP:
 			backend = INVALID_PROC_NUMBER;
 			break;
 		case RELPERSISTENCE_TEMP:
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 0572ab424e7..d0219396cc4 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -1158,6 +1158,7 @@ retry:
 	{
 		case RELPERSISTENCE_UNLOGGED:
 		case RELPERSISTENCE_PERMANENT:
+		case RELPERSISTENCE_GLOBAL_TEMP:
 			relation->rd_backend = INVALID_PROC_NUMBER;
 			relation->rd_islocaltemp = false;
 			break;
@@ -3653,6 +3654,7 @@ RelationBuildLocalRelation(const char *relname,
 	{
 		case RELPERSISTENCE_UNLOGGED:
 		case RELPERSISTENCE_PERMANENT:
+		case RELPERSISTENCE_GLOBAL_TEMP:
 			rel->rd_backend = INVALID_PROC_NUMBER;
 			rel->rd_islocaltemp = false;
 			break;
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index c56437d6057..0c42d81a0be 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -17453,6 +17453,8 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo)
 		 * ignore it when dumping if it was set in this case.
 		 */
 		appendPQExpBuffer(q, "CREATE %s%s %s",
+						  tbinfo->relpersistence == RELPERSISTENCE_GLOBAL_TEMP ?
+						  "GLOBAL TEMPORARY " :
 						  (tbinfo->relpersistence == RELPERSISTENCE_UNLOGGED &&
 						   tbinfo->relkind != RELKIND_PARTITIONED_TABLE) ?
 						  "UNLOGGED " : "",
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index af3935b0078..a33b6a0bcab 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -4311,10 +4311,12 @@ listTables(const char *tabtypes, const char *pattern, bool verbose, bool showSys
 						  "WHEN " CppAsString2(RELPERSISTENCE_PERMANENT) " THEN '%s' "
 						  "WHEN " CppAsString2(RELPERSISTENCE_TEMP) " THEN '%s' "
 						  "WHEN " CppAsString2(RELPERSISTENCE_UNLOGGED) " THEN '%s' "
+						  "WHEN " CppAsString2(RELPERSISTENCE_GLOBAL_TEMP) " THEN '%s' "
 						  "END as \"%s\"",
 						  gettext_noop("permanent"),
 						  gettext_noop("temporary"),
 						  gettext_noop("unlogged"),
+						  gettext_noop("global temporary"),
 						  gettext_noop("Persistence"));
 		translate_columns[cols_so_far] = true;
 
diff --git a/src/include/catalog/dependency.h b/src/include/catalog/dependency.h
index 2f3c1eae3c7..98de33d32a9 100644
--- a/src/include/catalog/dependency.h
+++ b/src/include/catalog/dependency.h
@@ -127,9 +127,11 @@ extern void recordDependencyOnSingleRelExpr(const ObjectAddress *depender,
 
 extern bool find_temp_object(const ObjectAddresses *addrs,
 							 bool local_temp_okay,
+							 bool include_gtt,
 							 ObjectAddress *foundobj);
 
-extern bool query_uses_temp_object(Query *query, ObjectAddress *temp_object);
+extern bool query_uses_temp_object(Query *query, bool include_gtt,
+								   ObjectAddress *temp_object);
 
 extern ObjectAddresses *new_object_addresses(void);
 
diff --git a/src/include/catalog/pg_class.h b/src/include/catalog/pg_class.h
index c4af599dc90..1f7ea2bf00b 100644
--- a/src/include/catalog/pg_class.h
+++ b/src/include/catalog/pg_class.h
@@ -183,6 +183,7 @@ MAKE_SYSCACHE(RELNAMENSP, pg_class_relname_nsp_index, 128);
 #define		  RELPERSISTENCE_PERMANENT	'p' /* regular table */
 #define		  RELPERSISTENCE_UNLOGGED	'u' /* unlogged permanent table */
 #define		  RELPERSISTENCE_TEMP		't' /* temporary table */
+#define		  RELPERSISTENCE_GLOBAL_TEMP 'g'	/* global temporary table */
 
 /* default selection for replica identity (primary key or nothing) */
 #define		  REPLICA_IDENTITY_DEFAULT	'd'
diff --git a/src/include/commands/sequence.h b/src/include/commands/sequence.h
index 2c3c4a3f074..97914a574bf 100644
--- a/src/include/commands/sequence.h
+++ b/src/include/commands/sequence.h
@@ -17,6 +17,7 @@
 #include "fmgr.h"
 #include "nodes/parsenodes.h"
 #include "parser/parse_node.h"
+#include "utils/relcache.h"
 
 typedef struct FormData_pg_sequence_data
 {
@@ -49,5 +50,6 @@ extern void DeleteSequenceTuple(Oid relid);
 extern void ResetSequence(Oid seq_relid);
 extern void SetSequence(Oid relid, int64 next, bool iscalled);
 extern void ResetSequenceCaches(void);
+extern void GttEnsureSequenceInitialized(Relation rel);
 
 #endif							/* SEQUENCE_H */
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index fa07ebf8ff7..246cbfd49ba 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -641,9 +641,20 @@ RelationCloseSmgr(Relation relation)
 	  (relation->rd_createSubid == InvalidSubTransactionId &&			\
 	   relation->rd_firstRelfilelocatorSubid == InvalidSubTransactionId)))
 
+/*
+ * RelationIsGlobalTemp
+ *		True if relation is a global temporary table.
+ */
+#define RelationIsGlobalTemp(relation) \
+	((relation)->rd_rel->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+
 /*
  * RelationUsesLocalBuffers
  *		True if relation's pages are stored in local buffers.
+ *
+ * Note: global temporary tables will use local buffers for per-session
+ * data storage once that infrastructure is implemented.  For now, their
+ * shared catalog storage uses shared buffers like permanent tables.
  */
 #define RelationUsesLocalBuffers(relation) \
 	((relation)->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
-- 
2.43.0



  [text/x-patch] 0002-Global-temporary-tables-per-session-data-isolation.patch (44.7K, ../[email protected]/3-0002-Global-temporary-tables-per-session-data-isolation.patch)
  download | inline diff:
From 7c04a7b997b6ae185b05cb7987dfb976e990cee5 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Fri, 13 Mar 2026 10:34:17 -0400
Subject: [PATCH 02/12] Global temporary tables: per-session data isolation

Each session that accesses a global temporary table now gets its own
private data storage, stored in local buffers.  Session A's INSERT
into a GTT is invisible to session B, matching the SQL standard
semantics for global temporary tables.

The implementation uses a backend-local hash table (in storage_gtt.c)
that maps the GTT OID to a per-session RelFileLocator.  Storage is
lazily created on first access via GttInitSessionStorage(), which is
called from RelationInitPhysicalAddr() when a GTT is encountered.

Per-session storage files use the temp-file naming convention
(t<ProcNumber>_<relfilenode>), so different sessions' files don't
collide.  Files are automatically cleaned up at session exit via a
before_shmem_exit callback.

Hash entries are kept in sync with transaction state by xact and
subxact callbacks registered on first use:

- On subxact or xact abort, entries whose create_subid matches the
  aborting subxact are removed; entries whose storage_subid matches
  have storage_created cleared (the corresponding files are unlinked
  by the normal PendingRelDelete machinery).

- On subxact commit, the tagged subids are reparented.

- On top-level commit, any entry marked drop_pending is removed.
  GttScheduleDropSessionStorage() is called from heap_drop_with_catalog
  to set the flag when a DROP TABLE runs on a GTT; this defers the
  hash-entry teardown until the DROP actually commits, so an aborted
  DROP leaves the entry intact.

GTTs are marked rd_islocaltemp=true in the relcache, so callers keyed
off that flag (the read-only-xact gate in COPY, extension-lock skips
in hio.c and the AM vacuum paths) treat them consistently with
regular temp tables.  Protecting a GTT against concurrent DROP/ALTER
while a peer session is idle with active data is deferred to a later
commit ("DDL safety via session-level locks").

Key changes:
- New storage_gtt.c/h: per-session storage hash, lifecycle, and
  xact/subxact callbacks
- heap.c: skip shared storage creation for GTTs at CREATE time, and
  call GttScheduleDropSessionStorage() from heap_drop_with_catalog
- relcache.c: redirect GTT physical address to session-local storage
  and set rd_islocaltemp=true for GTT relcache entries (in both
  RelationBuildDesc and RelationBuildLocalRelation, each with its own
  case so permanent/unlogged relations keep rd_islocaltemp=false)
- bufmgr.c: route GTT buffer operations through local buffer path
  using the new RELPERSISTENCE_IS_LOCAL() macro
- storage.c: use ProcNumberForTempRelations() for GTT storage
- dbsize.c: pg_relation_filepath on a GTT returns the current
  session's per-session path if initialized, else NULL
- dbcommands.c: CREATE DATABASE's WAL_LOG strategy skips GTTs when
  scanning the template's pg_class -- like local temp tables, they
  have no file at their catalog locator to copy
- pg_class.h: add RELPERSISTENCE_IS_LOCAL() convenience macro
---
 src/backend/access/table/tableam.c      |   6 +
 src/backend/access/transam/xloginsert.c |   6 +-
 src/backend/catalog/Makefile            |   1 +
 src/backend/catalog/heap.c              |  18 +
 src/backend/catalog/meson.build         |   1 +
 src/backend/catalog/storage.c           |   5 +-
 src/backend/catalog/storage_gtt.c       | 529 ++++++++++++++++++++++++
 src/backend/commands/createas.c         |  63 ++-
 src/backend/commands/dbcommands.c       |   7 +-
 src/backend/commands/sequence.c         |  13 +
 src/backend/commands/tablecmds.c        |  27 +-
 src/backend/storage/buffer/bufmgr.c     |  31 +-
 src/backend/utils/adt/dbsize.c          |  18 +-
 src/backend/utils/cache/relcache.c      |  37 +-
 src/include/catalog/pg_class.h          |   8 +
 src/include/catalog/storage_gtt.h       |  23 ++
 src/include/utils/rel.h                 |   7 +-
 src/tools/pgindent/typedefs.list        |   1 +
 18 files changed, 757 insertions(+), 44 deletions(-)
 create mode 100644 src/backend/catalog/storage_gtt.c
 create mode 100644 src/include/catalog/storage_gtt.h

diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c
index 68ff0966f1c..0a2cb72f331 100644
--- a/src/backend/access/table/tableam.c
+++ b/src/backend/access/table/tableam.c
@@ -30,6 +30,7 @@
 #include "storage/bufmgr.h"
 #include "storage/shmem.h"
 #include "storage/smgr.h"
+#include "catalog/storage_gtt.h"
 
 /*
  * Constants to control the behavior of block allocation to parallel workers
@@ -682,6 +683,11 @@ table_block_relation_size(Relation rel, ForkNumber forkNumber)
 {
 	uint64		nblocks = 0;
 
+	/* See RelationGetNumberOfBlocksInFork: unmaterialized GTTs are empty. */
+	if (RelationIsGlobalTemp(rel) &&
+		!GttHasSessionStorage(RelationGetRelid(rel)))
+		return 0;
+
 	/* InvalidForkNumber indicates returning the size for all forks */
 	if (forkNumber == InvalidForkNumber)
 	{
diff --git a/src/backend/access/transam/xloginsert.c b/src/backend/access/transam/xloginsert.c
index f2e10b82b7d..3088f308e5d 100644
--- a/src/backend/access/transam/xloginsert.c
+++ b/src/backend/access/transam/xloginsert.c
@@ -561,11 +561,11 @@ XLogSimpleInsertInt64(RmgrId rmid, uint8 info, int64 value)
 XLogRecPtr
 XLogGetFakeLSN(Relation rel)
 {
-	if (rel->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(rel->rd_rel->relpersistence))
 	{
 		/*
-		 * Temporary relations are only accessible in our session, so a simple
-		 * backend-local counter will do.
+		 * Temporary and global temporary relations are only accessible within
+		 * our session, so a simple backend-local counter will do.
 		 */
 		static XLogRecPtr counter = FirstNormalUnloggedLSN;
 
diff --git a/src/backend/catalog/Makefile b/src/backend/catalog/Makefile
index 26fa0c9b18c..1d6e49169b1 100644
--- a/src/backend/catalog/Makefile
+++ b/src/backend/catalog/Makefile
@@ -47,6 +47,7 @@ OBJS = \
 	pg_tablespace.o \
 	pg_type.o \
 	storage.o \
+	storage_gtt.o \
 	toasting.o
 
 include $(top_srcdir)/src/backend/common.mk
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 88087654de9..0811dfed681 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -54,6 +54,7 @@
 #include "catalog/pg_tablespace.h"
 #include "catalog/pg_type.h"
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "commands/tablecmds.h"
 #include "commands/typecmds.h"
 #include "common/int.h"
@@ -344,6 +345,14 @@ heap_create(const char *relname,
 		 */
 		if (!RelFileNumberIsValid(relfilenumber))
 			relfilenumber = relid;
+
+		/*
+		 * Global temporary tables need a relfilenode in the catalog (used as
+		 * the basis for per-session file naming), but don't create shared
+		 * storage -- per-session storage is created lazily.
+		 */
+		if (relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			create_storage = false;
 	}
 
 	/*
@@ -1901,6 +1910,15 @@ heap_drop_with_catalog(Oid relid)
 	if (RELKIND_HAS_STORAGE(rel->rd_rel->relkind))
 		RelationDropStorage(rel);
 
+	/*
+	 * For global temporary tables, also schedule release of the per-session
+	 * hash entry and the session-level lock.  Physical file unlinking goes
+	 * through the normal PendingRelDelete path above (rd_locator has been
+	 * redirected to the per-session locator).
+	 */
+	if (RelationIsGlobalTemp(rel))
+		GttScheduleDropSessionStorage(relid);
+
 	/* ensure that stats are dropped if transaction commits */
 	pgstat_drop_relation(rel);
 
diff --git a/src/backend/catalog/meson.build b/src/backend/catalog/meson.build
index 11d21c5ad6b..76059030fda 100644
--- a/src/backend/catalog/meson.build
+++ b/src/backend/catalog/meson.build
@@ -34,6 +34,7 @@ backend_sources += files(
   'pg_tablespace.c',
   'pg_type.c',
   'storage.c',
+  'storage_gtt.c',
   'toasting.c',
 )
 
diff --git a/src/backend/catalog/storage.c b/src/backend/catalog/storage.c
index febd8c246ca..ef21f89860b 100644
--- a/src/backend/catalog/storage.c
+++ b/src/backend/catalog/storage.c
@@ -135,8 +135,11 @@ RelationCreateStorage(RelFileLocator rlocator, char relpersistence,
 			needs_wal = false;
 			break;
 		case RELPERSISTENCE_UNLOGGED:
+			procNumber = INVALID_PROC_NUMBER;
+			needs_wal = false;
+			break;
 		case RELPERSISTENCE_GLOBAL_TEMP:
-			procNumber = INVALID_PROC_NUMBER;
+			procNumber = ProcNumberForTempRelations();
 			needs_wal = false;
 			break;
 		case RELPERSISTENCE_PERMANENT:
diff --git a/src/backend/catalog/storage_gtt.c b/src/backend/catalog/storage_gtt.c
new file mode 100644
index 00000000000..427e3e111e0
--- /dev/null
+++ b/src/backend/catalog/storage_gtt.c
@@ -0,0 +1,529 @@
+/*-------------------------------------------------------------------------
+ *
+ * storage_gtt.c
+ *	  Per-session storage management for global temporary tables.
+ *
+ * Global temporary tables have a shared catalog definition but per-session
+ * private data.  Each backend that accesses a GTT gets its own local
+ * storage files, stored in local buffers like regular temp tables.
+ *
+ * The mapping from GTT OID to per-session RelFileLocator is maintained in
+ * a backend-local hash table (gtt_storage_hash).  Storage is lazily
+ * created on first access and cleaned up at session end.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/catalog/storage_gtt.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/tableam.h"
+#include "access/xact.h"
+#include "catalog/pg_tablespace_d.h"
+#include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
+#include "common/hashfn.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "storage/ipc.h"
+#include "storage/bufmgr.h"
+#include "storage/procnumber.h"
+#include "storage/smgr.h"
+#include "utils/hsearch.h"
+#include "utils/inval.h"
+#include "utils/memutils.h"
+#include "utils/rel.h"
+#include "utils/relcache.h"
+
+/*
+ * Per-session state for a single global temporary table.
+ *
+ * The SubTransactionId fields track which (sub)transaction most recently
+ * performed an action that must be undone on abort:
+ *   - create_subid: subxact that added this entry to the hash
+ *     (InvalidSubTransactionId once the entry has survived to top-level
+ *     commit)
+ *   - storage_subid: subxact that most recently called RelationCreateStorage
+ * On subxact or xact abort of a given subid, the corresponding state is
+ * reverted.  On subxact commit, the subid is reparented.  See
+ * gtt_subxact_callback / gtt_xact_callback.
+ */
+typedef struct GttStorageEntry
+{
+	Oid			relid;			/* GTT's pg_class OID (hash key) */
+	RelFileLocator locator;		/* per-session physical storage location */
+	bool		storage_created;	/* has smgr file been created? */
+	bool		drop_pending;	/* entry scheduled for drop at xact commit */
+	SubTransactionId create_subid;	/* subxact that added this entry */
+	SubTransactionId storage_subid; /* subxact that created current storage */
+} GttStorageEntry;
+
+/* Backend-local hash table: GTT OID -> GttStorageEntry */
+static HTAB *gtt_storage_hash = NULL;
+
+/*
+ * True when any entry carries rollback-sensitive state (a valid
+ * create_subid/storage_subid, or drop_pending), letting the xact/subxact
+ * callbacks skip their full-hash scans in the common case of a transaction
+ * that established no such state.  Conservative: it is only cleared once a
+ * top-level transaction end has settled every entry.
+ */
+static bool gtt_xact_state_dirty = false;
+
+/* Local function prototypes */
+static void gtt_session_cleanup(int code, Datum arg);
+static void ensure_gtt_hash(void);
+static void gtt_xact_callback(XactEvent event, void *arg);
+static void gtt_subxact_callback(SubXactEvent event,
+								 SubTransactionId mySubid,
+								 SubTransactionId parentSubid,
+								 void *arg);
+static void gtt_remove_entry(GttStorageEntry *entry);
+static void gtt_revert_storage(GttStorageEntry *entry);
+static void gtt_remove_relids(List *to_remove);
+static void gtt_init_entry(GttStorageEntry *entry, Relation relation);
+
+/*
+ * ensure_gtt_hash
+ *		Create the backend-local hash table on first use.
+ */
+static void
+ensure_gtt_hash(void)
+{
+	HASHCTL		hashctl;
+
+	if (gtt_storage_hash != NULL)
+		return;
+
+	hashctl.keysize = sizeof(Oid);
+	hashctl.entrysize = sizeof(GttStorageEntry);
+	hashctl.hcxt = TopMemoryContext;
+	gtt_storage_hash = hash_create("GTT storage hash",
+								   32,	/* initial size */
+								   &hashctl,
+								   HASH_ELEM | HASH_BLOBS | HASH_CONTEXT);
+
+	/*
+	 * Register session cleanup to drop all per-session GTT storage files when
+	 * the backend exits, and xact/subxact callbacks that keep the hash in
+	 * sync with transaction state (see gtt_xact_callback).  The hash is
+	 * created exactly once per backend and never destroyed, so this cannot
+	 * register twice.
+	 */
+	before_shmem_exit(gtt_session_cleanup, (Datum) 0);
+	RegisterXactCallback(gtt_xact_callback, NULL);
+	RegisterSubXactCallback(gtt_subxact_callback, NULL);
+}
+
+/*
+ * GttInitSessionStorage
+ *		Ensure per-session local storage exists for the given GTT relation.
+ *
+ * This is called from RelationInitPhysicalAddr when a GTT is being set up
+ * in the relcache.  It creates a per-session storage file if one doesn't
+ * exist yet, and fills in the relation's rd_locator and rd_backend to
+ * point to the session-local file.
+ *
+ * The per-session relfilenode is allocated from the local OID counter
+ * (same mechanism as regular temp tables).
+ */
+void
+GttInitSessionStorage(Relation relation)
+{
+	GttStorageEntry *entry;
+	bool		found;
+	Oid			relid = RelationGetRelid(relation);
+
+	ensure_gtt_hash();
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+											&relid,
+											HASH_ENTER,
+											&found);
+
+	if (!found)
+		gtt_init_entry(entry, relation);
+
+	/* Point the relation at our per-session storage */
+	relation->rd_locator = entry->locator;
+	relation->rd_backend = ProcNumberForTempRelations();
+
+	/*
+	 * No physical file is created here.  Reads of unmaterialized storage
+	 * complete without one (the zero-blocks short-circuits in
+	 * bufmgr.c/tableam.c report the relation empty), so the file is deferred
+	 * to GttEnsureSessionStorage at the first genuine data access.
+	 */
+}
+
+/*
+ * gtt_init_entry
+ *		Initialize a newly created per-session map entry from the relation's
+ *		current catalog state.
+ */
+static void
+gtt_init_entry(GttStorageEntry *entry, Relation relation)
+{
+	/*
+	 * Set up the locator using the catalog's tablespace and database, but in
+	 * this backend's temp namespace.
+	 */
+	if (relation->rd_rel->reltablespace)
+		entry->locator.spcOid = relation->rd_rel->reltablespace;
+	else
+		entry->locator.spcOid = MyDatabaseTableSpace;
+
+	if (entry->locator.spcOid == GLOBALTABLESPACE_OID)
+		entry->locator.dbOid = InvalidOid;
+	else
+		entry->locator.dbOid = MyDatabaseId;
+
+	/*
+	 * Use the catalog relfilenode as the per-session relfilenode. Since each
+	 * backend uses its own proc number as the backend ID in the file path
+	 * (t_<procnum>_<relfilenode>), the same relfilenode won't collide between
+	 * sessions.
+	 */
+	entry->locator.relNumber = relation->rd_rel->relfilenode;
+	entry->storage_created = false;
+	entry->drop_pending = false;
+	entry->create_subid = GetCurrentSubTransactionId();
+	gtt_xact_state_dirty = true;
+	entry->storage_subid = InvalidSubTransactionId;
+}
+
+/*
+ * GttEnsureSessionStorage
+ *		Materialize this session's physical storage for a GTT.
+ *
+ * Called the first time a GTT is genuinely accessed for data: it creates the
+ * per-session file (registered for delete-at-abort via RelationCreateStorage).
+ * Reads never call this -- an unmaterialized GTT reads as empty.
+ */
+void
+GttEnsureSessionStorage(Relation relation)
+{
+	GttStorageEntry *entry;
+	Oid			relid = RelationGetRelid(relation);
+
+	Assert(RelationIsGlobalTemp(relation));
+
+	if (gtt_storage_hash == NULL)
+		elog(ERROR, "no per-session storage map for global temporary table \"%s\"",
+			 RelationGetRelationName(relation));
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+											HASH_FIND, NULL);
+	if (entry == NULL)
+		elog(ERROR, "no per-session storage entry for global temporary table \"%s\"",
+			 RelationGetRelationName(relation));
+
+	if (entry->storage_created)
+		return;
+
+	RelationCreateStorage(entry->locator, RELPERSISTENCE_GLOBAL_TEMP, true);
+	entry->storage_created = true;
+	entry->storage_subid = GetCurrentSubTransactionId();
+	gtt_xact_state_dirty = true;
+}
+
+/*
+ * GttHasSessionStorage
+ *		Check if the current session has initialized storage for a GTT.
+ *
+ * Used by pg_relation_filepath to decide whether to surface the current
+ * session's private file path for a GTT (or NULL when this session has
+ * not yet accessed it).
+ */
+bool
+GttHasSessionStorage(Oid relid)
+{
+	if (gtt_storage_hash == NULL)
+		return false;
+
+	return hash_search(gtt_storage_hash, &relid, HASH_FIND, NULL) != NULL;
+}
+
+/*
+ * gtt_remove_entry
+ *		Release per-session state for a GTT and remove its hash entry.
+ *
+ * This is the common teardown path invoked from gtt_session_cleanup,
+ * from the commit-side of a scheduled drop, and from abort handling when
+ * an entry was created in the aborting (sub)transaction.  Physical file
+ * removal via PendingRelDelete is handled separately (by smgr's abort
+ * cleanup or RelationDropStorage).
+ */
+static void
+gtt_remove_entry(GttStorageEntry *entry)
+{
+	Oid			relid = entry->relid;
+
+	hash_search(gtt_storage_hash, &relid, HASH_REMOVE, NULL);
+}
+
+/*
+ * GttScheduleDropSessionStorage
+ *		Mark a GTT's per-session entry for cleanup at xact commit.
+ *
+ * Called from heap_drop_with_catalog when a DROP TABLE runs on a GTT.
+ * The actual removal of the hash entry happens when the transaction
+ * commits (see gtt_xact_callback).  If the transaction aborts, the drop
+ * is abandoned and the entry stays.
+ *
+ * Physical file removal is handled by RelationDropStorage (via the
+ * standard PendingRelDelete mechanism).  This function only coordinates
+ * the session-local metadata.
+ */
+void
+GttScheduleDropSessionStorage(Oid relid)
+{
+	GttStorageEntry *entry;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+											&relid,
+											HASH_FIND,
+											NULL);
+	if (entry != NULL)
+	{
+		entry->drop_pending = true;
+		gtt_xact_state_dirty = true;
+	}
+}
+
+/*
+ * gtt_revert_storage
+ *		Undo lazily-created storage state on (sub)transaction abort.
+ *
+ * The files themselves have been unlinked by PendingRelDelete; reset the
+ * bookkeeping so the next access re-creates the storage.
+ */
+static void
+gtt_revert_storage(GttStorageEntry *entry)
+{
+	entry->storage_created = false;
+	entry->storage_subid = InvalidSubTransactionId;
+}
+
+/*
+ * gtt_remove_relids
+ *		Remove the hash entries named by a list of relation OIDs.
+ *
+ * Shared tail of the xact and subxact callbacks: victims are collected
+ * during the hash scan (hash_seq_search is fragile if the current entry is
+ * deleted) and removed here afterwards.
+ */
+static void
+gtt_remove_relids(List *to_remove)
+{
+	GttStorageEntry *entry;
+
+	foreach_oid(relid, to_remove)
+	{
+		entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+												&relid,
+												HASH_FIND,
+												NULL);
+		if (entry != NULL)
+			gtt_remove_entry(entry);
+	}
+	list_free(to_remove);
+}
+
+/*
+ * gtt_xact_callback
+ *		Reconcile gtt_storage_hash with transaction completion.
+ *
+ * On top-level commit: finalise any scheduled drops (remove entries) and
+ * clear per-entry subxact state, since the surviving entries are now
+ * permanent for the session.
+ *
+ * On top-level abort: roll back any state that was established by the
+ * aborting transaction.  Entries created in this xact are removed;
+ * entries whose storage was established in this xact have storage_created
+ * cleared — PendingRelDelete has already unlinked the associated files.
+ */
+static void
+gtt_xact_callback(XactEvent event, void *arg)
+{
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+	List	   *to_remove = NIL;
+	List	   *to_invalidate = NIL;
+	ListCell   *lc;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	if (event != XACT_EVENT_COMMIT && event != XACT_EVENT_ABORT &&
+		event != XACT_EVENT_PARALLEL_COMMIT &&
+		event != XACT_EVENT_PARALLEL_ABORT)
+		return;
+
+	/*
+	 * The common case is a transaction that touched no GTT state at all;
+	 * don't pay for a full-hash scan at every commit for the rest of the
+	 * session's life just because a GTT was once used.
+	 */
+	if (!gtt_xact_state_dirty)
+		return;
+
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		bool		remove = false;
+
+		if (event == XACT_EVENT_COMMIT ||
+			event == XACT_EVENT_PARALLEL_COMMIT)
+		{
+			if (entry->drop_pending)
+				remove = true;
+			else
+			{
+				entry->create_subid = InvalidSubTransactionId;
+				entry->storage_subid = InvalidSubTransactionId;
+			}
+		}
+		else
+		{
+			/*
+			 * Top-level abort.  Either remove the entry (if it was created in
+			 * this xact) or clear storage_created (if storage was created in
+			 * this xact).  In both cases the per-session file has been
+			 * unlinked by PendingRelDelete and the relcache still has a
+			 * cached rd_locator pointing at it; force a relcache invalidation
+			 * so the next access re-runs RelationInitPhysicalAddr ->
+			 * GttInitSessionStorage and recreates the storage.
+			 */
+			if (entry->create_subid != InvalidSubTransactionId)
+			{
+				remove = true;
+				to_invalidate = lappend_oid(to_invalidate, entry->relid);
+			}
+			else
+			{
+				if (entry->storage_subid != InvalidSubTransactionId)
+				{
+					gtt_revert_storage(entry);
+					to_invalidate = lappend_oid(to_invalidate, entry->relid);
+				}
+				entry->drop_pending = false;
+			}
+		}
+
+		if (remove)
+			to_remove = lappend_oid(to_remove, entry->relid);
+	}
+
+	gtt_remove_relids(to_remove);
+
+	foreach(lc, to_invalidate)
+		RelationCacheInvalidateEntry(lfirst_oid(lc));
+	list_free(to_invalidate);
+
+	/* every entry has now been settled */
+	gtt_xact_state_dirty = false;
+}
+
+/*
+ * gtt_subxact_callback
+ *		Reconcile gtt_storage_hash with subtransaction completion.
+ *
+ * On subxact commit, reparent subxact-tagged state to the parent.  On
+ * subxact abort, revert state established in the aborting subxact: whole
+ * entry for newly-created ones, storage_created for older entries that
+ * had new storage created in the aborting subxact.
+ */
+static void
+gtt_subxact_callback(SubXactEvent event,
+					 SubTransactionId mySubid,
+					 SubTransactionId parentSubid,
+					 void *arg)
+{
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+	List	   *to_remove = NIL;
+	List	   *to_invalidate = NIL;
+	ListCell   *lc;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	if (event != SUBXACT_EVENT_COMMIT_SUB && event != SUBXACT_EVENT_ABORT_SUB)
+		return;
+
+	/* As in gtt_xact_callback, skip the scans if nothing can need work. */
+	if (!gtt_xact_state_dirty)
+		return;
+
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (event == SUBXACT_EVENT_COMMIT_SUB)
+		{
+			if (entry->create_subid == mySubid)
+				entry->create_subid = parentSubid;
+			if (entry->storage_subid == mySubid)
+				entry->storage_subid = parentSubid;
+		}
+		else					/* SUBXACT_EVENT_ABORT_SUB */
+		{
+			if (entry->create_subid == mySubid)
+			{
+				to_remove = lappend_oid(to_remove, entry->relid);
+				to_invalidate = lappend_oid(to_invalidate, entry->relid);
+				continue;
+			}
+			if (entry->storage_subid == mySubid)
+			{
+				gtt_revert_storage(entry);
+				to_invalidate = lappend_oid(to_invalidate, entry->relid);
+			}
+		}
+	}
+
+	gtt_remove_relids(to_remove);
+
+	/* See gtt_xact_callback: invalidate relcache for any killed storage. */
+	foreach(lc, to_invalidate)
+		RelationCacheInvalidateEntry(lfirst_oid(lc));
+	list_free(to_invalidate);
+}
+
+/*
+ * gtt_session_cleanup
+ *		Drop all per-session GTT storage files at backend exit.
+ *
+ * This runs from before_shmem_exit.  Entries scheduled for drop by a
+ * committed DROP TABLE have already been removed by gtt_xact_callback,
+ * so anything still in the hash represents live per-session storage
+ * that should be unlinked.
+ */
+static void
+gtt_session_cleanup(int code, Datum arg)
+{
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->storage_created)
+		{
+			SMgrRelation srel;
+
+			srel = smgropen(entry->locator, ProcNumberForTempRelations());
+			smgrdounlinkall(&srel, 1, false);
+		}
+	}
+}
diff --git a/src/backend/commands/createas.c b/src/backend/commands/createas.c
index 6dbb831ca89..558364c2af6 100644
--- a/src/backend/commands/createas.c
+++ b/src/backend/commands/createas.c
@@ -28,7 +28,10 @@
 #include "access/reloptions.h"
 #include "access/tableam.h"
 #include "access/xact.h"
+#include "access/genam.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_class.h"
+#include "catalog/storage_gtt.h"
 #include "catalog/toasting.h"
 #include "commands/createas.h"
 #include "commands/matview.h"
@@ -302,6 +305,7 @@ ExecCreateTableAs(ParseState *pstate, CreateTableAsStmt *stmt,
 		List	   *rewritten;
 		PlannedStmt *plan;
 		QueryDesc  *queryDesc;
+		int			cursorOptions;
 
 		Assert(!is_matview);
 
@@ -319,9 +323,18 @@ ExecCreateTableAs(ParseState *pstate, CreateTableAsStmt *stmt,
 		query = linitial_node(Query, rewritten);
 		Assert(query->commandType == CMD_SELECT);
 
-		/* plan the query */
+		/*
+		 * Plan the query.  The inserting query may normally run in parallel,
+		 * but a global temporary table's per-session storage is created
+		 * lazily on the first write, which cannot happen in parallel mode.
+		 * The target relation is not in the query's range table, so
+		 * max_parallel_hazard() can't see it; suppress parallelism here.
+		 */
+		cursorOptions = CURSOR_OPT_PARALLEL_OK;
+		if (into->rel && into->rel->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			cursorOptions = 0;
 		plan = pg_plan_query(query, pstate->p_sourcetext,
-							 CURSOR_OPT_PARALLEL_OK, params, NULL);
+							 cursorOptions, params, NULL);
 
 		/*
 		 * Use a snapshot with an updated command ID to ensure this query sees
@@ -532,6 +545,52 @@ intorel_startup(DestReceiver *self, int operation, TupleDesc typeinfo)
 	 */
 	intoRelationDesc = table_open(intoRelationAddr.objectId, AccessExclusiveLock);
 
+	/*
+	 * For global temporary tables, pre-materialize the per-session storage
+	 * now, before the executor may enter parallel mode.  The SELECT variant
+	 * of CTAS already suppresses parallel planning via cursorOptions=0, but
+	 * the EXECUTE variant uses a pre-compiled plan that may have
+	 * parallelModeNeeded=true (e.g. under debug_parallel_query=regress).
+	 * GttEnsureSessionStorage would be called lazily on the first write, but
+	 * RelationCreateStorage asserts !IsInParallelMode(), so we force
+	 * materialization here while we are still outside parallel mode.
+	 *
+	 * We also pre-materialize the TOAST table (which inherits GTT persistence)
+	 * and trigger its deferred index build.  Without this, a CTAS producing
+	 * wide rows could fail when heap_insert opens the TOAST table and calls
+	 * GttEnsureSessionStorage in the middle of parallel execution.
+	 */
+	if (!into->skipData && RelationIsGlobalTemp(intoRelationDesc))
+	{
+		GttEnsureSessionStorage(intoRelationDesc);
+		if (OidIsValid(intoRelationDesc->rd_rel->reltoastrelid))
+		{
+			Relation	toastrel;
+			List	   *indexlist;
+			ListCell   *ilc;
+
+			toastrel = table_open(intoRelationDesc->rd_rel->reltoastrelid,
+								  AccessShareLock);
+			GttEnsureSessionStorage(toastrel);
+
+			/*
+			 * Opening each TOAST index triggers GttBuildIndexIfNeeded, which
+			 * will find the TOAST heap storage materialized and build_deferred
+			 * set (set during create_ctas_internal because the heap was then
+			 * empty), and so will materialize and initialize the index.
+			 */
+			indexlist = RelationGetIndexList(toastrel);
+			foreach(ilc, indexlist)
+			{
+				Relation	idxrel = index_open(lfirst_oid(ilc), AccessShareLock);
+
+				index_close(idxrel, AccessShareLock);
+			}
+			list_free(indexlist);
+			table_close(toastrel, AccessShareLock);
+		}
+	}
+
 	/*
 	 * Make sure the constructed table does not have RLS enabled.
 	 *
diff --git a/src/backend/commands/dbcommands.c b/src/backend/commands/dbcommands.c
index f0819d15ab7..17198b1cf72 100644
--- a/src/backend/commands/dbcommands.c
+++ b/src/backend/commands/dbcommands.c
@@ -410,11 +410,14 @@ ScanSourceDatabasePgClassTuple(HeapTupleData *tuple, Oid tbid, Oid dbid,
 	 * are inaccessible outside of the session that created them, which must
 	 * be gone already, and couldn't connect to a different database if it
 	 * still existed. autovacuum will eventually remove the pg_class entries
-	 * as well.
+	 * as well.  Global temporary tables have no file at their catalog locator
+	 * (per-session storage is created lazily in each backend's temp
+	 * namespace), so there is nothing to copy for them either; their catalog
+	 * definitions travel with pg_class itself.
 	 */
 	if (classForm->reltablespace == GLOBALTABLESPACE_OID ||
 		!RELKIND_HAS_STORAGE(classForm->relkind) ||
-		classForm->relpersistence == RELPERSISTENCE_TEMP)
+		RELPERSISTENCE_IS_LOCAL(classForm->relpersistence))
 		return NULL;
 
 	/*
diff --git a/src/backend/commands/sequence.c b/src/backend/commands/sequence.c
index 8eef726a228..15ad0490ac1 100644
--- a/src/backend/commands/sequence.c
+++ b/src/backend/commands/sequence.c
@@ -48,6 +48,7 @@
 #include "utils/resowner.h"
 #include "utils/syscache.h"
 #include "utils/varlena.h"
+#include "catalog/storage_gtt.h"
 
 
 /*
@@ -330,6 +331,10 @@ ResetSequence(Oid seq_relid)
 static void
 fill_seq_with_data(Relation rel, HeapTuple tuple)
 {
+	/* a GTT sequence's per-session storage is created lazily */
+	if (rel->rd_rel->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		GttEnsureSessionStorage(rel);
+
 	fill_seq_fork_with_data(rel, tuple, MAIN_FORKNUM);
 
 	if (rel->rd_rel->relpersistence == RELPERSISTENCE_UNLOGGED)
@@ -446,6 +451,14 @@ GttEnsureSequenceInitialized(Relation rel)
 	if (rel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
 		return;
 
+	/*
+	 * Sequences are the documented exception to lazy storage creation: the
+	 * one-row contract (SELECT last_value FROM seq, psql's \d) requires a
+	 * readable row, so opening a GTT sequence materializes its one-page file
+	 * and seeds it.
+	 */
+	GttEnsureSessionStorage(rel);
+
 	if (RelationGetNumberOfBlocks(rel) > 0)
 		return;
 
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 82700c49ba0..d4e83e5658c 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -843,8 +843,7 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	 * Check consistency of arguments
 	 */
 	if (stmt->oncommit != ONCOMMIT_NOOP
-		&& stmt->relation->relpersistence != RELPERSISTENCE_TEMP
-		&& stmt->relation->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
+		&& !RELPERSISTENCE_IS_LOCAL(stmt->relation->relpersistence))
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_TABLE_DEFINITION),
 				 errmsg("ON COMMIT can only be used on temporary tables")));
@@ -2785,20 +2784,16 @@ MergeAttributes(List *columns, const List *supers, char relpersistence,
 		 * that inheritance allows that case.
 		 */
 		if (is_partition &&
-			relation->rd_rel->relpersistence != RELPERSISTENCE_TEMP &&
-			relation->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP &&
-			(relpersistence == RELPERSISTENCE_TEMP ||
-			 relpersistence == RELPERSISTENCE_GLOBAL_TEMP))
+			!RELPERSISTENCE_IS_LOCAL(relation->rd_rel->relpersistence) &&
+			RELPERSISTENCE_IS_LOCAL(relpersistence))
 			ereport(ERROR,
 					(errcode(ERRCODE_WRONG_OBJECT_TYPE),
 					 errmsg("cannot create a temporary relation as partition of permanent relation \"%s\"",
 							RelationGetRelationName(relation))));
 
 		/* Permanent rels cannot inherit from temporary ones */
-		if (relpersistence != RELPERSISTENCE_TEMP &&
-			relpersistence != RELPERSISTENCE_GLOBAL_TEMP &&
-			(relation->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
-			 RelationIsGlobalTemp(relation)))
+		if (!RELPERSISTENCE_IS_LOCAL(relpersistence) &&
+			RELPERSISTENCE_IS_LOCAL(relation->rd_rel->relpersistence))
 			ereport(ERROR,
 					(errcode(ERRCODE_WRONG_OBJECT_TYPE),
 					 errmsg(!is_partition
@@ -20783,20 +20778,16 @@ ATExecAttachPartition(List **wqueue, Relation rel, PartitionCmd *cmd,
 						   RelationGetRelationName(attachrel))));
 
 	/* If the parent is permanent, so must be all of its partitions. */
-	if (rel->rd_rel->relpersistence != RELPERSISTENCE_TEMP &&
-		rel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP &&
-		(attachrel->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
-		 RelationIsGlobalTemp(attachrel)))
+	if (!RELPERSISTENCE_IS_LOCAL(rel->rd_rel->relpersistence) &&
+		RELPERSISTENCE_IS_LOCAL(attachrel->rd_rel->relpersistence))
 		ereport(ERROR,
 				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
 				 errmsg("cannot attach a temporary relation as partition of permanent relation \"%s\"",
 						RelationGetRelationName(rel))));
 
 	/* Temp parent cannot have a partition that is itself not a temp */
-	if ((rel->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
-		 RelationIsGlobalTemp(rel)) &&
-		attachrel->rd_rel->relpersistence != RELPERSISTENCE_TEMP &&
-		attachrel->rd_rel->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(rel->rd_rel->relpersistence) &&
+		!RELPERSISTENCE_IS_LOCAL(attachrel->rd_rel->relpersistence))
 		ereport(ERROR,
 				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
 				 errmsg("cannot attach a permanent relation as partition of temporary relation \"%s\"",
diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
index cd0bcdf2fe8..66f0c6586f1 100644
--- a/src/backend/storage/buffer/bufmgr.c
+++ b/src/backend/storage/buffer/bufmgr.c
@@ -44,6 +44,7 @@
 #include "catalog/pg_tablespace_d.h"
 #endif
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "catalog/storage_xlog.h"
 #include "common/hashfn.h"
 #include "executor/instrument.h"
@@ -1246,7 +1247,7 @@ PinBufferForBlock(Relation rel,
 									   smgr->smgr_rlocator.locator.relNumber,
 									   smgr->smgr_rlocator.backend);
 
-	if (persistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(persistence))
 		bufHdr = LocalBufferAlloc(smgr, forkNum, blockNum, foundPtr);
 	else
 		bufHdr = BufferAlloc(smgr, persistence, forkNum, blockNum,
@@ -1328,7 +1329,7 @@ ReadBuffer_common(Relation rel, SMgrRelation smgr, char smgr_persistence,
 		IOContext	io_context;
 		IOObject	io_object;
 
-		if (persistence == RELPERSISTENCE_TEMP)
+		if (RELPERSISTENCE_IS_LOCAL(persistence))
 		{
 			io_context = IOCONTEXT_NORMAL;
 			io_object = IOOBJECT_TEMP_RELATION;
@@ -1392,7 +1393,7 @@ StartReadBuffersImpl(ReadBuffersOperation *operation,
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("cannot access temporary tables of other sessions")));
 
-	if (operation->persistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(operation->persistence))
 	{
 		io_context = IOCONTEXT_NORMAL;
 		io_object = IOOBJECT_TEMP_RELATION;
@@ -1693,7 +1694,7 @@ TrackBufferHit(IOObject io_object, IOContext io_context,
 									  smgr->smgr_rlocator.backend,
 									  true);
 
-	if (persistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(persistence))
 		pgBufferUsage.local_blks_hit += 1;
 	else
 		pgBufferUsage.shared_blks_hit += 1;
@@ -1764,7 +1765,7 @@ WaitReadBuffers(ReadBuffersOperation *operation)
 	IOObject	io_object;
 	bool		needed_wait = false;
 
-	if (operation->persistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(operation->persistence))
 	{
 		io_context = IOCONTEXT_NORMAL;
 		io_object = IOOBJECT_TEMP_RELATION;
@@ -1954,7 +1955,7 @@ AsyncReadBuffers(ReadBuffersOperation *operation, int *nblocks_progress)
 	instr_time	io_start;
 	StartBufferIOResult status;
 
-	if (persistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(persistence))
 	{
 		io_context = IOCONTEXT_NORMAL;
 		io_object = IOOBJECT_TEMP_RELATION;
@@ -1973,7 +1974,7 @@ AsyncReadBuffers(ReadBuffersOperation *operation, int *nblocks_progress)
 	if (flags & READ_BUFFERS_SYNCHRONOUSLY)
 		ioh_flags |= PGAIO_HF_SYNCHRONOUS;
 
-	if (persistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(persistence))
 		ioh_flags |= PGAIO_HF_REFERENCES_LOCAL;
 
 	/*
@@ -2134,7 +2135,7 @@ AsyncReadBuffers(ReadBuffersOperation *operation, int *nblocks_progress)
 	pgaio_io_set_handle_data_32(ioh, (uint32 *) io_buffers, io_buffers_len);
 
 	pgaio_io_register_callbacks(ioh,
-								persistence == RELPERSISTENCE_TEMP ?
+								RELPERSISTENCE_IS_LOCAL(persistence) ?
 								PGAIO_HCB_LOCAL_BUFFER_READV :
 								PGAIO_HCB_SHARED_BUFFER_READV,
 								flags);
@@ -2157,7 +2158,7 @@ AsyncReadBuffers(ReadBuffersOperation *operation, int *nblocks_progress)
 	pgstat_count_io_op_time(io_object, io_context, IOOP_READ,
 							io_start, 1, io_buffers_len * BLCKSZ);
 
-	if (persistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(persistence))
 		pgBufferUsage.local_blks_read += io_buffers_len;
 	else
 		pgBufferUsage.shared_blks_read += io_buffers_len;
@@ -2767,7 +2768,7 @@ ExtendBufferedRelCommon(BufferManagerRelation bmr,
 										 BMR_GET_SMGR(bmr)->smgr_rlocator.backend,
 										 extend_by);
 
-	if (bmr.relpersistence == RELPERSISTENCE_TEMP)
+	if (RELPERSISTENCE_IS_LOCAL(bmr.relpersistence))
 		first_block = ExtendBufferedRelLocal(bmr, fork, flags,
 											 extend_by, extend_upto,
 											 buffers, &extend_by);
@@ -4654,6 +4655,16 @@ FlushUnlockedBuffer(BufferDesc *buf, SMgrRelation reln,
 BlockNumber
 RelationGetNumberOfBlocksInFork(Relation relation, ForkNumber forkNum)
 {
+	/*
+	 * A global temporary table whose per-session storage has not been
+	 * materialized has no file: it is empty by definition.  Reporting zero
+	 * blocks here (and in table_block_relation_size) is what lets reads of
+	 * never-written GTTs complete without creating any storage.
+	 */
+	if (RelationIsGlobalTemp(relation) &&
+		!GttHasSessionStorage(RelationGetRelid(relation)))
+		return 0;
+
 	if (RELKIND_HAS_TABLE_AM(relation->rd_rel->relkind))
 	{
 		/*
diff --git a/src/backend/utils/adt/dbsize.c b/src/backend/utils/adt/dbsize.c
index 7243ec0dfd9..93b45133adf 100644
--- a/src/backend/utils/adt/dbsize.c
+++ b/src/backend/utils/adt/dbsize.c
@@ -19,6 +19,7 @@
 #include "catalog/pg_authid.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_tablespace.h"
+#include "catalog/storage_gtt.h"
 #include "commands/tablespace.h"
 #include "miscadmin.h"
 #include "storage/fd.h"
@@ -1020,9 +1021,24 @@ pg_relation_filepath(PG_FUNCTION_ARGS)
 	{
 		case RELPERSISTENCE_UNLOGGED:
 		case RELPERSISTENCE_PERMANENT:
-		case RELPERSISTENCE_GLOBAL_TEMP:
 			backend = INVALID_PROC_NUMBER;
 			break;
+		case RELPERSISTENCE_GLOBAL_TEMP:
+
+			/*
+			 * GTTs have no shared storage; each backend has a private file
+			 * named after its own proc number.  If the current session has
+			 * initialized this GTT, report that file; otherwise NULL,
+			 * matching the "not yet accessed" state.
+			 */
+			if (GttHasSessionStorage(relid))
+				backend = ProcNumberForTempRelations();
+			else
+			{
+				ReleaseSysCache(tuple);
+				PG_RETURN_NULL();
+			}
+			break;
 		case RELPERSISTENCE_TEMP:
 			if (isTempOrTempToastNamespace(relform->relnamespace))
 				backend = ProcNumberForTempRelations();
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index d0219396cc4..3ffed51f9e9 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -64,6 +64,7 @@
 #include "catalog/pg_type.h"
 #include "catalog/schemapg.h"
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "commands/policy.h"
 #include "commands/publicationcmds.h"
 #include "commands/trigger.h"
@@ -1158,10 +1159,23 @@ retry:
 	{
 		case RELPERSISTENCE_UNLOGGED:
 		case RELPERSISTENCE_PERMANENT:
-		case RELPERSISTENCE_GLOBAL_TEMP:
 			relation->rd_backend = INVALID_PROC_NUMBER;
 			relation->rd_islocaltemp = false;
 			break;
+		case RELPERSISTENCE_GLOBAL_TEMP:
+
+			/*
+			 * GTT data is per-session: no other backend can see our rows.
+			 * Mark rd_islocaltemp so callers keyed off that flag (the
+			 * read-only-xact gate in COPY, extension-lock skips in hio.c and
+			 * the AM vacuum paths, etc.) treat GTTs consistently with regular
+			 * temp tables.  rd_backend is left INVALID_PROC_NUMBER here and
+			 * will be set to ProcNumberForTempRelations by
+			 * GttInitSessionStorage when physical-address init runs.
+			 */
+			relation->rd_backend = INVALID_PROC_NUMBER;
+			relation->rd_islocaltemp = true;
+			break;
 		case RELPERSISTENCE_TEMP:
 			if (isTempOrTempToastNamespace(relation->rd_rel->relnamespace))
 			{
@@ -1341,6 +1355,16 @@ RelationInitPhysicalAddr(Relation relation)
 	if (!RELKIND_HAS_STORAGE(relation->rd_rel->relkind))
 		return;
 
+	/*
+	 * Global temporary tables use per-session local storage.  Redirect the
+	 * relation's physical address to the session-local file.
+	 */
+	if (RelationIsGlobalTemp(relation))
+	{
+		GttInitSessionStorage(relation);
+		return;
+	}
+
 	if (relation->rd_rel->reltablespace)
 		relation->rd_locator.spcOid = relation->rd_rel->reltablespace;
 	else
@@ -3654,10 +3678,19 @@ RelationBuildLocalRelation(const char *relname,
 	{
 		case RELPERSISTENCE_UNLOGGED:
 		case RELPERSISTENCE_PERMANENT:
-		case RELPERSISTENCE_GLOBAL_TEMP:
 			rel->rd_backend = INVALID_PROC_NUMBER;
 			rel->rd_islocaltemp = false;
 			break;
+		case RELPERSISTENCE_GLOBAL_TEMP:
+
+			/*
+			 * As in RelationBuildDesc: GTT data is per-session, so mark
+			 * rd_islocaltemp; rd_backend is set by GttInitSessionStorage when
+			 * physical-address init runs.
+			 */
+			rel->rd_backend = INVALID_PROC_NUMBER;
+			rel->rd_islocaltemp = true;
+			break;
 		case RELPERSISTENCE_TEMP:
 			Assert(isTempOrTempToastNamespace(relnamespace));
 			rel->rd_backend = ProcNumberForTempRelations();
diff --git a/src/include/catalog/pg_class.h b/src/include/catalog/pg_class.h
index 1f7ea2bf00b..7a811a26bdc 100644
--- a/src/include/catalog/pg_class.h
+++ b/src/include/catalog/pg_class.h
@@ -185,6 +185,14 @@ MAKE_SYSCACHE(RELNAMENSP, pg_class_relname_nsp_index, 128);
 #define		  RELPERSISTENCE_TEMP		't' /* temporary table */
 #define		  RELPERSISTENCE_GLOBAL_TEMP 'g'	/* global temporary table */
 
+/*
+ * Does this persistence type use local (per-backend) buffers?
+ * Both session-local temp tables and global temporary tables store
+ * their data in local buffers.
+ */
+#define RELPERSISTENCE_IS_LOCAL(p) \
+	((p) == RELPERSISTENCE_TEMP || (p) == RELPERSISTENCE_GLOBAL_TEMP)
+
 /* default selection for replica identity (primary key or nothing) */
 #define		  REPLICA_IDENTITY_DEFAULT	'd'
 /* no replica identity is logged for this relation */
diff --git a/src/include/catalog/storage_gtt.h b/src/include/catalog/storage_gtt.h
new file mode 100644
index 00000000000..cefa5b5dbca
--- /dev/null
+++ b/src/include/catalog/storage_gtt.h
@@ -0,0 +1,23 @@
+/*-------------------------------------------------------------------------
+ *
+ * storage_gtt.h
+ *	  Per-session storage management for global temporary tables.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/catalog/storage_gtt.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef STORAGE_GTT_H
+#define STORAGE_GTT_H
+
+#include "utils/rel.h"
+
+extern void GttInitSessionStorage(Relation relation);
+extern void GttEnsureSessionStorage(Relation relation);
+extern bool GttHasSessionStorage(Oid relid);
+extern void GttScheduleDropSessionStorage(Oid relid);
+
+#endif							/* STORAGE_GTT_H */
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 246cbfd49ba..696591b7d22 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -651,13 +651,10 @@ RelationCloseSmgr(Relation relation)
 /*
  * RelationUsesLocalBuffers
  *		True if relation's pages are stored in local buffers.
- *
- * Note: global temporary tables will use local buffers for per-session
- * data storage once that infrastructure is implemented.  For now, their
- * shared catalog storage uses shared buffers like permanent tables.
  */
 #define RelationUsesLocalBuffers(relation) \
-	((relation)->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
+	((relation)->rd_rel->relpersistence == RELPERSISTENCE_TEMP || \
+	 (relation)->rd_rel->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
 
 /*
  * RELATION_IS_LOCAL
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 1969d467c1d..6ef53535c7e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1187,6 +1187,7 @@ GroupingSet
 GroupingSetData
 GroupingSetKind
 GroupingSetsPath
+GttStorageEntry
 GucAction
 GucBoolAssignHook
 GucBoolCheckHook
-- 
2.43.0



  [text/x-patch] 0003-Global-temporary-tables-ON-COMMIT-DELETE-ROWS-suppor.patch (17.9K, ../[email protected]/4-0003-Global-temporary-tables-ON-COMMIT-DELETE-ROWS-suppor.patch)
  download | inline diff:
From 2908562525ef529d5a4064b067b21e114e3a42a0 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Fri, 13 Mar 2026 11:06:21 -0400
Subject: [PATCH 03/12] Global temporary tables: ON COMMIT DELETE ROWS support

Add support for the ON COMMIT DELETE ROWS clause on global temporary
tables.  The on-commit action is persisted as a reloption
(on_commit_delete) in the catalog so that all sessions can discover it
when they first access the GTT.

Because GTTs use local buffers, relation_open sets
XACT_FLAGS_ACCESSEDTEMPNAMESPACE for them the same way it does for
regular temp tables, which means the existing
PreCommit_on_commit_actions() path (iterating the on_commits list
populated by register_on_commit_action in heap_create_with_catalog)
already truncates the session-local files at commit.  We rely on it
here: the GTT is registered with ONCOMMIT_DELETE_ROWS exactly like a
regular temp table, and heap_truncate wipes the per-session file.

GttInitSessionStorage refreshes entry->on_commit_delete from
rd_options on every call.  rd_options is not populated on the very
first invocation from heap_create_with_catalog (the reloption has
not been written to pg_class yet), so the flag is not usable until
the subsequent CCI-driven relcache rebuild.  Later commits consult
the flag in PreCommit_gtt_on_commit(), which is introduced here as
a skeleton: at this stage there is no per-session state to clean
up, so the body is a stub iteration that filters the same entries
future commits will act on.  We hook it into both
CommitTransaction and PrepareTransaction, right after
PreCommit_on_commit_actions.

The on_commit_delete reloption is internal: DefineRelation and
ATExecSetRelOptions reject attempts to set or reset it directly from
user SQL.  The reloption is only added implicitly when ON COMMIT
DELETE ROWS is specified on CREATE of a GTT.

TOAST storage is reclaimed along with the heap: each heap entry in the
per-session map records its toast relation's OID (captured from the
relcache), and the commit-time truncation queues the toast heap as
well, without any catalog access from the commit-time hook.

PrepareTransaction does not call PreCommit_gtt_on_commit: any GTT
access sets XACT_FLAGS_ACCESSEDTEMPNAMESPACE, which makes the PREPARE
fail shortly afterwards, so commit-time truncation work there would
always be wasted.
---
 src/backend/access/common/reloptions.c |  13 +-
 src/backend/access/transam/xact.c      |  10 ++
 src/backend/catalog/heap.c             |  10 +-
 src/backend/catalog/storage_gtt.c      | 189 +++++++++++++++++++++++++
 src/backend/commands/tablecmds.c       |  42 ++++++
 src/include/catalog/storage_gtt.h      |   1 +
 src/include/utils/rel.h                |   1 +
 7 files changed, 263 insertions(+), 3 deletions(-)

diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 3e832c3797e..7d452c5e129 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -162,6 +162,15 @@ static relopt_bool boolRelOpts[] =
 		},
 		true
 	},
+	{
+		{
+			"on_commit_delete",
+			"Truncate global temporary table data on commit",
+			RELOPT_KIND_HEAP,
+			AccessExclusiveLock
+		},
+		false
+	},
 	/* list terminator */
 	{{NULL}}
 };
@@ -2025,7 +2034,9 @@ default_reloptions(Datum reloptions, bool validate, relopt_kind kind)
 		{"vacuum_truncate", RELOPT_TYPE_TERNARY,
 		offsetof(StdRdOptions, vacuum_truncate)},
 		{"vacuum_max_eager_freeze_failure_rate", RELOPT_TYPE_REAL,
-		offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)}
+		offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)},
+		{"on_commit_delete", RELOPT_TYPE_BOOL,
+		offsetof(StdRdOptions, on_commit_delete)}
 	};
 
 	return (bytea *) build_reloptions(reloptions, validate, kind,
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index 5586fbe5b07..4efb6a33f03 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -36,6 +36,7 @@
 #include "catalog/namespace.h"
 #include "catalog/pg_enum.h"
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "commands/async.h"
 #include "commands/tablecmds.h"
 #include "commands/trigger.h"
@@ -2352,6 +2353,9 @@ CommitTransaction(void)
 	 */
 	PreCommit_on_commit_actions();
 
+	/* Truncate ON COMMIT DELETE ROWS global temporary tables */
+	PreCommit_gtt_on_commit();
+
 	/*
 	 * Synchronize files that are created and not WAL-logged during this
 	 * transaction. This must happen before AtEOXact_RelationMap(), so that we
@@ -2614,6 +2618,12 @@ PrepareTransaction(void)
 	 */
 	PreCommit_on_commit_actions();
 
+	/*
+	 * No PreCommit_gtt_on_commit() here: any access to a GTT sets
+	 * XACT_FLAGS_ACCESSEDTEMPNAMESPACE, which makes the PREPARE fail just
+	 * below, so commit-time GTT truncation work would always be wasted.
+	 */
+
 	/*
 	 * Synchronize files that are created and not WAL-logged during this
 	 * transaction. This must happen before EndPrepare(), so that we don't see
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 0811dfed681..27b3f7e72be 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -1544,9 +1544,15 @@ heap_create_with_catalog(const char *relname,
 	StoreConstraints(new_rel_desc, cooked_constraints, is_internal);
 
 	/*
-	 * If there's a special on-commit action, remember it
+	 * If there's a special on-commit action, remember it.  Global temporary
+	 * tables manage their ON COMMIT DELETE ROWS truncation through
+	 * PreCommit_gtt_on_commit instead, since heap_truncate would escalate to
+	 * AccessExclusiveLock at every commit, blocking on peers' ordinary
+	 * transaction-level locks even though only this session's private storage
+	 * is affected; skip the generic registration here for GTTs.
 	 */
-	if (oncommit != ONCOMMIT_NOOP)
+	if (oncommit != ONCOMMIT_NOOP &&
+		relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
 		register_on_commit_action(relid, oncommit);
 
 	/*
diff --git a/src/backend/catalog/storage_gtt.c b/src/backend/catalog/storage_gtt.c
index 427e3e111e0..e16310a0329 100644
--- a/src/backend/catalog/storage_gtt.c
+++ b/src/backend/catalog/storage_gtt.c
@@ -21,8 +21,10 @@
  */
 #include "postgres.h"
 
+#include "access/table.h"
 #include "access/tableam.h"
 #include "access/xact.h"
+#include "catalog/heap.h"
 #include "catalog/pg_tablespace_d.h"
 #include "catalog/storage.h"
 #include "catalog/storage_gtt.h"
@@ -55,8 +57,11 @@
 typedef struct GttStorageEntry
 {
 	Oid			relid;			/* GTT's pg_class OID (hash key) */
+	Oid			toast_relid;	/* toast relation for heap entries, InvalidOid
+								 * if none / not a heap */
 	RelFileLocator locator;		/* per-session physical storage location */
 	bool		storage_created;	/* has smgr file been created? */
+	bool		on_commit_delete;	/* truncate data on commit? */
 	bool		drop_pending;	/* entry scheduled for drop at xact commit */
 	SubTransactionId create_subid;	/* subxact that added this entry */
 	SubTransactionId storage_subid; /* subxact that created current storage */
@@ -86,6 +91,7 @@ static void gtt_remove_entry(GttStorageEntry *entry);
 static void gtt_revert_storage(GttStorageEntry *entry);
 static void gtt_remove_relids(List *to_remove);
 static void gtt_init_entry(GttStorageEntry *entry, Relation relation);
+static void gtt_truncate_smgr(GttStorageEntry *entry);
 
 /*
  * ensure_gtt_hash
@@ -148,6 +154,41 @@ GttInitSessionStorage(Relation relation)
 	if (!found)
 		gtt_init_entry(entry, relation);
 
+	/*
+	 * Refresh on_commit_delete from the catalog reloption.  rd_options is not
+	 * populated on the very first call from heap_create, so the CREATE path
+	 * initially leaves this flag cleared; a subsequent relcache build (after
+	 * CCI during the same CREATE) supplies the reloption.
+	 *
+	 * The truncation itself is done from PreCommit_gtt_on_commit -- we do not
+	 * register an OnCommitItem because heap_truncate's AccessExclusiveLock
+	 * would conflict with peer sessions' session-level AccessShareLock on the
+	 * same GTT.
+	 */
+	if (relation->rd_options != NULL &&
+		relation->rd_rel->relkind == RELKIND_RELATION)
+	{
+		/*
+		 * The relkind check matters: rd_options is only StdRdOptions for
+		 * plain tables -- for other relkinds it can be a smaller
+		 * kind-specific struct, and reading on_commit_delete from it would
+		 * run off the end of the allocation.
+		 */
+		StdRdOptions *opts = (StdRdOptions *) relation->rd_options;
+
+		entry->on_commit_delete = opts->on_commit_delete;
+	}
+
+	/*
+	 * Remember the toast relation for heap entries, so the commit-time
+	 * on-commit-delete truncation can reach it without catalog access.  As
+	 * with on_commit_delete, rd_rel is not fully populated on the very first
+	 * call during CREATE; later relcache builds fill it in.
+	 */
+	if (relation->rd_rel->relkind == RELKIND_RELATION &&
+		OidIsValid(relation->rd_rel->reltoastrelid))
+		entry->toast_relid = relation->rd_rel->reltoastrelid;
+
 	/* Point the relation at our per-session storage */
 	relation->rd_locator = entry->locator;
 	relation->rd_backend = ProcNumberForTempRelations();
@@ -194,6 +235,8 @@ gtt_init_entry(GttStorageEntry *entry, Relation relation)
 	entry->create_subid = GetCurrentSubTransactionId();
 	gtt_xact_state_dirty = true;
 	entry->storage_subid = InvalidSubTransactionId;
+	entry->on_commit_delete = false;
+	entry->toast_relid = InvalidOid;
 }
 
 /*
@@ -497,6 +540,152 @@ gtt_subxact_callback(SubXactEvent event,
 	list_free(to_invalidate);
 }
 
+/*
+ * gtt_truncate_smgr
+ *		Truncate one entry's per-session storage to zero blocks via smgr.
+ *
+ * We cannot call RelationTruncate (which requires a Relation) because
+ * opening relations during commit-time hooks corrupts the relcache state
+ * that subsequent xacts rely on for DROP TABLE.  Truncating directly via
+ * smgr is sufficient: the storage is per-session and not visible to any
+ * other backend, so neither the AccessExclusiveLock RelationTruncate
+ * documents nor the relcache inval message it sends are needed for
+ * correctness here.
+ *
+ * The btree _bt_getroot fast path keeps a copy of the metapage in
+ * rd_amcache; that cache is dropped lazily by GttBuildIndexIfNeeded the
+ * next time the index is opened (added in a later commit), so we do not
+ * touch it here.
+ */
+static void
+gtt_truncate_smgr(GttStorageEntry *entry)
+{
+	SMgrRelation reln;
+	ForkNumber	forks[MAX_FORKNUM + 1];
+	BlockNumber old_blocks[MAX_FORKNUM + 1];
+	BlockNumber new_blocks[MAX_FORKNUM + 1];
+	int			nforks = 0;
+
+	if (!entry->storage_created)
+		return;
+
+	reln = smgropen(entry->locator, ProcNumberForTempRelations());
+
+	/* tolerate an already-vanished file (defense in depth) */
+	if (!smgrexists(reln, MAIN_FORKNUM))
+		return;
+
+	forks[nforks] = MAIN_FORKNUM;
+	old_blocks[nforks] = smgrnblocks(reln, MAIN_FORKNUM);
+	new_blocks[nforks] = 0;
+	nforks++;
+
+	if (smgrexists(reln, FSM_FORKNUM))
+	{
+		forks[nforks] = FSM_FORKNUM;
+		old_blocks[nforks] = smgrnblocks(reln, FSM_FORKNUM);
+		new_blocks[nforks] = 0;
+		nforks++;
+	}
+	if (smgrexists(reln, VISIBILITYMAP_FORKNUM))
+	{
+		forks[nforks] = VISIBILITYMAP_FORKNUM;
+		old_blocks[nforks] = smgrnblocks(reln, VISIBILITYMAP_FORKNUM);
+		new_blocks[nforks] = 0;
+		nforks++;
+	}
+
+	/*
+	 * Skip the truncation entirely if every fork is already empty: there is
+	 * then nothing in the local buffer pool for this relation either, so
+	 * smgrtruncate's buffer-drop pass and sinval message would be pure
+	 * overhead.  This matters because PreCommit_gtt_on_commit re-truncates
+	 * every ON COMMIT DELETE ROWS GTT the session has opened, at every
+	 * qualifying commit, written-to or not.
+	 */
+	while (nforks > 0 && old_blocks[nforks - 1] == 0)
+		nforks--;
+	if (nforks == 0)
+		return;
+
+	smgrtruncate(reln, forks, nforks, old_blocks, new_blocks);
+}
+
+/*
+ * PreCommit_gtt_on_commit
+ *		Truncate ON COMMIT DELETE ROWS GTTs at commit.
+ *
+ * Generic on-commit truncation in PreCommit_on_commit_actions cannot be
+ * used for GTTs: heap_truncate's AccessExclusiveLock would block on peers'
+ * ordinary transaction-level locks at every commit, and opening the relation
+ * via table_open at commit-time -- even with NoLock -- destabilises the
+ * relcache enough to break a subsequent DROP TABLE in the next xact.  So
+ * we register no OnCommitItem for GTTs (heap_create_with_catalog
+ * suppresses the generic registration; see register_on_commit_action()
+ * callers in heap.c) and truncate each session's local storage here
+ * directly through smgr, using the per-session locator that
+ * GttInitSessionStorage already recorded in our hash.
+ */
+void
+PreCommit_gtt_on_commit(void)
+{
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+	List	   *toast_relids = NIL;
+	ListCell   *lc;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	/*
+	 * Match PreCommit_on_commit_actions's optimisation: skip when no temp
+	 * namespace was accessed in this xact, since any GTT we have storage for
+	 * is necessarily empty.
+	 */
+	if (!(MyXactFlags & XACT_FLAGS_ACCESSEDTEMPNAMESPACE))
+		return;
+
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (!entry->on_commit_delete || !entry->storage_created)
+			continue;
+
+		/*
+		 * A heap whose main fork is already empty has not been written since
+		 * its last truncation; skip it -- and thereby its toast -- so that an
+		 * idle ON COMMIT DELETE ROWS table costs each commit no more than
+		 * this block-count probe.
+		 */
+		if (smgrnblocks(smgropen(entry->locator, ProcNumberForTempRelations()),
+						MAIN_FORKNUM) == 0)
+			continue;
+
+		gtt_truncate_smgr(entry);
+
+		/*
+		 * Queue the toast relation too (if this session ever wrote toasted
+		 * values, an entry for it exists).  Truncating just the heap would
+		 * orphan the toast rows for good: nothing else ever deletes them, and
+		 * autovacuum never visits GTTs.
+		 */
+		if (OidIsValid(entry->toast_relid))
+			toast_relids = lappend_oid(toast_relids, entry->toast_relid);
+	}
+
+	foreach(lc, toast_relids)
+	{
+		Oid			toast_relid = lfirst_oid(lc);
+
+		entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+												&toast_relid,
+												HASH_FIND, NULL);
+		if (entry != NULL && entry->storage_created)
+			gtt_truncate_smgr(entry);
+	}
+	list_free(toast_relids);
+}
+
 /*
  * gtt_session_cleanup
  *		Drop all per-session GTT storage files at backend exit.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index d4e83e5658c..5596d0f5573 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -397,6 +397,7 @@ typedef struct PartitionIndexExtDepEntry
 	((child_is_partition) ? DEPENDENCY_AUTO : DEPENDENCY_NORMAL)
 
 static void truncate_check_rel(Oid relid, Form_pg_class reltuple);
+static void CheckInternalGttReloption(List *options);
 static void truncate_check_perms(Oid relid, Form_pg_class reltuple);
 static void truncate_check_activity(Relation rel);
 static void RangeVarCallbackForTruncate(const RangeVar *relation,
@@ -787,6 +788,28 @@ static List *collectPartitionIndexExtDeps(List *partitionOids);
 static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
 static void freePartitionIndexExtDeps(List *extDepState);
 
+/*
+ * CheckInternalGttReloption
+ *		Reject user-supplied settings of internal GTT reloptions.
+ *
+ * The on_commit_delete reloption is internal: it persists the ON COMMIT
+ * DELETE ROWS action for a global temporary table so other sessions can
+ * discover it.  Users must not set or reset it directly via CREATE TABLE
+ * ... WITH (...) or ALTER TABLE SET/RESET (...).
+ */
+static void
+CheckInternalGttReloption(List *options)
+{
+	foreach_node(DefElem, def, options)
+	{
+		if (strcmp(def->defname, "on_commit_delete") == 0)
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("on_commit_delete is an internal reloption and cannot be set directly"),
+					errhint("Use ON COMMIT DELETE ROWS when creating a global temporary table."));
+	}
+}
+
 /* ----------------------------------------------------------------
  *		DefineRelation
  *				Creates a new relation.
@@ -979,6 +1002,22 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	if (!OidIsValid(ownerId))
 		ownerId = GetUserId();
 
+	/* Reject direct use of internal GTT reloptions */
+	CheckInternalGttReloption(stmt->options);
+
+	/*
+	 * For global temporary tables with ON COMMIT DELETE ROWS, persist the
+	 * on-commit action as a reloption so that other sessions can discover it.
+	 */
+	if (stmt->oncommit == ONCOMMIT_DELETE_ROWS
+		&& stmt->relation->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+	{
+		DefElem    *def = makeDefElem("on_commit_delete",
+									  (Node *) makeBoolean(true), -1);
+
+		stmt->options = lappend(stmt->options, def);
+	}
+
 	/*
 	 * Parse and validate reloptions, if any.
 	 */
@@ -16985,6 +17024,9 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	if (defList == NIL && operation != AT_ReplaceRelOptions)
 		return;					/* nothing to do */
 
+	/* Reject direct SET/RESET of internal GTT reloptions */
+	CheckInternalGttReloption(defList);
+
 	pgclass = table_open(RelationRelationId, RowExclusiveLock);
 
 	/* Fetch heap tuple */
diff --git a/src/include/catalog/storage_gtt.h b/src/include/catalog/storage_gtt.h
index cefa5b5dbca..9e8b8f1b713 100644
--- a/src/include/catalog/storage_gtt.h
+++ b/src/include/catalog/storage_gtt.h
@@ -19,5 +19,6 @@ extern void GttInitSessionStorage(Relation relation);
 extern void GttEnsureSessionStorage(Relation relation);
 extern bool GttHasSessionStorage(Oid relid);
 extern void GttScheduleDropSessionStorage(Oid relid);
+extern void PreCommit_gtt_on_commit(void);
 
 #endif							/* STORAGE_GTT_H */
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 696591b7d22..d711113313d 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -356,6 +356,7 @@ typedef struct StdRdOptions
 	 * to freeze. 0 if disabled, -1 if unspecified.
 	 */
 	double		vacuum_max_eager_freeze_failure_rate;
+	bool		on_commit_delete;	/* GTT: truncate data on commit */
 } StdRdOptions;
 
 #define HEAP_MIN_FILLFACTOR			10
-- 
2.43.0



  [text/x-patch] 0004-Global-temporary-tables-per-session-index-support-an.patch (54.8K, ../[email protected]/5-0004-Global-temporary-tables-per-session-index-support-an.patch)
  download | inline diff:
From e932c39bf27ed9cf9b435af416a04eead31d53ea Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Fri, 13 Mar 2026 12:10:37 -0400
Subject: [PATCH 04/12] Global temporary tables: per-session index support and
 TRUNCATE

GTT indexes have shared catalog definitions but need per-session data,
just like GTT heap tables.  When a new session first opens a GTT index
via index_open(), the storage file exists but is empty (created by
GttInitSessionStorage).  This commit adds lazy per-session index
initialization that calls the AM's ambuild callback to set up the
index structure (e.g. btree metapage) for the session.

Key implementation details:

- GttBuildIndexIfNeeded() is called from relation_open(), the single
  chokepoint every open funnels through (covering index_open as well
  as direct relation_open callers like pgstattuple and amcheck).  It
  checks whether the index has already been built in this session
  and, if not, calls ambuild directly.

- We use ambuild rather than index_build to avoid updating the shared
  pg_class statistics, which would be inappropriate for a per-session
  lazy build.

- Indexes created in the current transaction (rd_createSubid != 0) are
  skipped, since index_create() handles the initial build via
  index_build().

- A gtt_building_index guard flag prevents recursive builds when
  ambuild triggers relcache invalidation that leads back to
  index_open().

- After ambuild the hash entry is re-fetched because the hash may
  have been resized during the build; NULL is handled rather than
  asserted, so a concurrent relcache invalidation that removed the
  entry doesn't crash production builds.

The per-session build is tracked via an index_subid field so that
aborts of the (sub)transaction that performed the build clear the
index_built flag and allow the index to be rebuilt on next access.

TRUNCATE support:

TRUNCATE of a GTT is transaction-safe: RelationSetNewRelfilenumber()
gains a GTT branch that leaves the shared pg_class row untouched
(every session derives its private storage path from the catalog
relfilenode) and swaps only this session's storage mapping to a new,
empty file.  The file-level work is transactional through the usual
PendingRelDelete entries; the session-local mapping and per-entry
state are rolled back on abort by a new undo log (GttSwapUndo) kept
in storage_gtt.c, with reparenting on subtransaction commit.  A
ROLLBACK therefore restores both the rows and the index state.

ExecuteTruncateGuts uses this for the GTT heap, its indexes, the
toast table and the toast index, recording serializable rw-conflicts
just like the regular path.  Indexes are swapped to empty files and
lazily rebuilt by GttBuildIndexIfNeeded on next access; a lazy build
that happens mid-transaction stays rollback-safe because the abort
callback applies the swap undo before the index_subid processing
clears index_built.

CLUSTER, REINDEX, SET TABLESPACE, SET LOGGED, and heap rewrites
(which would rotate the shared catalog relfilenode itself) remain
blocked for GTTs; GttInitSessionStorage now only asserts that the
session mapping holds a valid relfilenumber, since a swap may make
it legitimately diverge from the catalog value.

PreCommit_gtt_on_commit() gains an index_built reset pass keyed on a
new heap_relid field carried on index entries (the parent heap OID
captured from rd_index->indrelid when the entry is first populated).
The pass walks the session's GTT hash without opening any relation:
opening a relation inside PreCommit_gtt_on_commit, after
PreCommit_on_commit_actions has already truncated via heap_truncate,
leaves the relation in a state where the next DROP TABLE fails to
reopen it.  Using the cached heap_relid lets us match indexes to
their freshly-truncated parent without any catalog or relcache
access here.

The session-storage truncation lives in GttTruncateInSession(), shared
by TRUNCATE and by DISCARD TEMP / DISCARD ALL: a GTT's definition must
survive DISCARD, but its per-session contents are session state and are
cleared (with GTT sequences reset to their start values), keeping
pooled connections from leaking one client's data to the next.  Both
callers are transaction-safe through the same swap-undo machinery.
---
 contrib/pgstattuple/pgstatindex.c    |  72 ++-
 src/backend/access/brin/brin.c       |  13 +
 src/backend/access/common/relation.c |  15 +-
 src/backend/access/gin/ginutil.c     |  17 +
 src/backend/access/nbtree/nbtpage.c  |  10 +
 src/backend/catalog/storage_gtt.c    | 682 ++++++++++++++++++++++++++-
 src/backend/commands/discard.c       |   3 +
 src/backend/commands/tablecmds.c     | 106 ++++-
 src/backend/utils/cache/relcache.c   |  42 ++
 src/include/catalog/storage_gtt.h    |   7 +
 src/include/commands/tablecmds.h     |   1 +
 src/tools/pgindent/typedefs.list     |   1 +
 12 files changed, 917 insertions(+), 52 deletions(-)

diff --git a/contrib/pgstattuple/pgstatindex.c b/contrib/pgstattuple/pgstatindex.c
index 3a3f2637bd9..e6d5bbe596f 100644
--- a/contrib/pgstattuple/pgstatindex.c
+++ b/contrib/pgstattuple/pgstatindex.c
@@ -40,6 +40,7 @@
 #include "storage/read_stream.h"
 #include "utils/rel.h"
 #include "utils/varlena.h"
+#include "catalog/storage_gtt.h"
 
 
 /*
@@ -221,6 +222,7 @@ pgstatindex_impl(Relation rel, FunctionCallInfo fcinfo)
 	BlockRangeReadStreamPrivate p;
 	ReadStream *stream;
 	BlockNumber startblk;
+	bool		materialized;
 
 	if (!IS_INDEX(rel) || !IS_BTREE(rel))
 		ereport(ERROR,
@@ -250,9 +252,20 @@ pgstatindex_impl(Relation rel, FunctionCallInfo fcinfo)
 				 errmsg("index \"%s\" is not valid",
 						RelationGetRelationName(rel))));
 
+	/*
+	 * A global temporary table's per-session index storage may not have been
+	 * materialized in this session; there is then no metapage to read and
+	 * nothing to report beyond zeros.
+	 */
+	materialized = !RelationIsGlobalTemp(rel) ||
+		GttSessionIndexUsable(RelationGetRelid(rel));
+
+	memset(&indexStat, 0, sizeof(indexStat));
+
 	/*
 	 * Read metapage
 	 */
+	if (materialized)
 	{
 		Buffer		buffer = ReadBufferExtended(rel, MAIN_FORKNUM, 0, RBM_NORMAL, bstrategy);
 		Page		page = BufferGetPage(buffer);
@@ -279,11 +292,11 @@ pgstatindex_impl(Relation rel, FunctionCallInfo fcinfo)
 	/*
 	 * Scan all blocks except the metapage (0th page) using streaming reads
 	 */
-	nblocks = RelationGetNumberOfBlocks(rel);
+	nblocks = materialized ? RelationGetNumberOfBlocks(rel) : 0;
 	startblk = BTREE_METAPAGE + 1;
 
 	p.current_blocknum = startblk;
-	p.last_exclusive = nblocks;
+	p.last_exclusive = Max(nblocks, startblk);
 
 	/*
 	 * It is safe to use batchmode as block_range_read_stream_cb takes no
@@ -368,6 +381,7 @@ pgstatindex_impl(Relation rel, FunctionCallInfo fcinfo)
 		values[j++] = psprintf("%d", indexStat.version);
 		values[j++] = psprintf("%d", indexStat.level);
 		values[j++] = psprintf(INT64_FORMAT,
+							   !materialized ? (int64) 0 :
 							   (1 + /* include the metapage in index_size */
 								indexStat.leaf_pages +
 								indexStat.internal_pages +
@@ -564,19 +578,27 @@ pgstatginindex_internal(Oid relid, FunctionCallInfo fcinfo)
 				 errmsg("index \"%s\" is not valid",
 						RelationGetRelationName(rel))));
 
+	memset(&stats, 0, sizeof(stats));
+
 	/*
-	 * Read metapage
+	 * Read metapage -- unless this is a global temporary table's index whose
+	 * per-session storage has not been materialized; that has no metapage and
+	 * nothing to report beyond zeros.
 	 */
-	buffer = ReadBuffer(rel, GIN_METAPAGE_BLKNO);
-	LockBuffer(buffer, GIN_SHARE);
-	page = BufferGetPage(buffer);
-	metadata = GinPageGetMeta(page);
+	if (!RelationIsGlobalTemp(rel) ||
+		GttSessionIndexUsable(RelationGetRelid(rel)))
+	{
+		buffer = ReadBuffer(rel, GIN_METAPAGE_BLKNO);
+		LockBuffer(buffer, GIN_SHARE);
+		page = BufferGetPage(buffer);
+		metadata = GinPageGetMeta(page);
 
-	stats.version = metadata->ginVersion;
-	stats.pending_pages = metadata->nPendingPages;
-	stats.pending_tuples = metadata->nPendingHeapTuples;
+		stats.version = metadata->ginVersion;
+		stats.pending_pages = metadata->nPendingPages;
+		stats.pending_tuples = metadata->nPendingHeapTuples;
 
-	UnlockReleaseBuffer(buffer);
+		UnlockReleaseBuffer(buffer);
+	}
 	relation_close(rel, AccessShareLock);
 
 	/*
@@ -654,16 +676,26 @@ pgstathashindex(PG_FUNCTION_ARGS)
 				 errmsg("index \"%s\" is not valid",
 						RelationGetRelationName(rel))));
 
-	/* Get the information we need from the metapage. */
+	/*
+	 * Get the information we need from the metapage -- unless this is a
+	 * global temporary table's index whose per-session storage has not been
+	 * materialized; that has no metapage and nothing to report beyond zeros.
+	 */
 	memset(&stats, 0, sizeof(stats));
-	metabuf = _hash_getbuf(rel, HASH_METAPAGE, HASH_READ, LH_META_PAGE);
-	metap = HashPageGetMeta(BufferGetPage(metabuf));
-	stats.version = metap->hashm_version;
-	stats.space_per_page = metap->hashm_bsize;
-	_hash_relbuf(rel, metabuf);
+	if (!RelationIsGlobalTemp(rel) ||
+		GttSessionIndexUsable(RelationGetRelid(rel)))
+	{
+		metabuf = _hash_getbuf(rel, HASH_METAPAGE, HASH_READ, LH_META_PAGE);
+		metap = HashPageGetMeta(BufferGetPage(metabuf));
+		stats.version = metap->hashm_version;
+		stats.space_per_page = metap->hashm_bsize;
+		_hash_relbuf(rel, metabuf);
 
-	/* Get the current relation length */
-	nblocks = RelationGetNumberOfBlocks(rel);
+		/* Get the current relation length */
+		nblocks = RelationGetNumberOfBlocks(rel);
+	}
+	else
+		nblocks = 0;
 
 	/* prepare access strategy for this index */
 	bstrategy = GetAccessStrategy(BAS_BULKREAD);
@@ -672,7 +704,7 @@ pgstathashindex(PG_FUNCTION_ARGS)
 	startblk = HASH_METAPAGE + 1;
 
 	p.current_blocknum = startblk;
-	p.last_exclusive = nblocks;
+	p.last_exclusive = Max(nblocks, startblk);
 
 	/*
 	 * It is safe to use batchmode as block_range_read_stream_cb takes no
diff --git a/src/backend/access/brin/brin.c b/src/backend/access/brin/brin.c
index bdb30752e09..3fcf938f063 100644
--- a/src/backend/access/brin/brin.c
+++ b/src/backend/access/brin/brin.c
@@ -46,6 +46,7 @@
 #include "utils/rel.h"
 #include "utils/tuplesort.h"
 #include "utils/wait_event.h"
+#include "catalog/storage_gtt.h"
 
 /* Magic numbers for parallel state sharing */
 #define PARALLEL_KEY_BRIN_SHARED		UINT64CONST(0xB000000000000001)
@@ -1656,6 +1657,18 @@ brinGetStats(Relation index, BrinStatsData *stats)
 	Page		metapage;
 	BrinMetaPageData *metadata;
 
+	/*
+	 * An unmaterialized GTT index has no metapage to read; report it empty so
+	 * that planning does not materialize per-session storage.
+	 */
+	if (RelationIsGlobalTemp(index) &&
+		!GttSessionIndexUsable(RelationGetRelid(index)))
+	{
+		stats->pagesPerRange = BrinGetPagesPerRange(index);
+		stats->revmapNumPages = 0;
+		return;
+	}
+
 	metabuffer = ReadBuffer(index, BRIN_METAPAGE_BLKNO);
 	LockBuffer(metabuffer, BUFFER_LOCK_SHARE);
 	metapage = BufferGetPage(metabuffer);
diff --git a/src/backend/access/common/relation.c b/src/backend/access/common/relation.c
index 57eca0ee635..3e414fb0881 100644
--- a/src/backend/access/common/relation.c
+++ b/src/backend/access/common/relation.c
@@ -23,6 +23,7 @@
 #include "access/relation.h"
 #include "access/xact.h"
 #include "catalog/namespace.h"
+#include "catalog/storage_gtt.h"
 #include "commands/sequence.h"
 #include "pgstat.h"
 #include "storage/lmgr.h"
@@ -35,15 +36,19 @@ static void relation_open_gtt_prepare(Relation r);
 /*
  * relation_open_gtt_prepare
  *		Lazily materialize the session-local pieces of a global temporary
- *		relation that need more than bare storage: sequences must be seeded
- *		with their initial tuple.  Doing this here, at the single chokepoint
- *		every open funnels through, covers direct relation_open callers
- *		(executor scans of a sequence) as well as the sequence functions.
+ *		relation that need more than the bare storage file created at
+ *		relcache-build time: indexes must be built and sequences seeded
+ *		with their initial tuple.  Doing this here, at the single
+ *		chokepoint every open funnels through, covers direct
+ *		relation_open callers (pgstattuple, amcheck, executor scans of a
+ *		sequence) as well as index_open.
  */
 static void
 relation_open_gtt_prepare(Relation r)
 {
-	if (r->rd_rel->relkind == RELKIND_SEQUENCE)
+	if (r->rd_rel->relkind == RELKIND_INDEX)
+		GttBuildIndexIfNeeded(r);
+	else if (r->rd_rel->relkind == RELKIND_SEQUENCE)
 		GttEnsureSequenceInitialized(r);
 }
 
diff --git a/src/backend/access/gin/ginutil.c b/src/backend/access/gin/ginutil.c
index e7cba81d477..1a7e6318311 100644
--- a/src/backend/access/gin/ginutil.c
+++ b/src/backend/access/gin/ginutil.c
@@ -431,6 +431,7 @@ cmpEntries(const void *a, const void *b, void *arg)
 #define ST_DEFINE
 #define ST_DECLARE
 #include "lib/sort_template.h"
+#include "catalog/storage_gtt.h"
 
 /*
  * Extract the index key values from an indexable item
@@ -581,6 +582,22 @@ ginGetStats(Relation index, GinStatsData *stats)
 	Page		metapage;
 	GinMetaPageData *metadata;
 
+	/*
+	 * An unmaterialized GTT index has no metapage to read; report it empty so
+	 * that planning does not materialize per-session storage.
+	 */
+	if (RelationIsGlobalTemp(index) &&
+		!GttSessionIndexUsable(RelationGetRelid(index)))
+	{
+		stats->nPendingPages = 0;
+		stats->nTotalPages = 0;
+		stats->nEntryPages = 0;
+		stats->nDataPages = 0;
+		stats->nEntries = 0;
+		stats->ginVersion = GIN_CURRENT_VERSION;
+		return;
+	}
+
 	metabuffer = ReadBuffer(index, GIN_METAPAGE_BLKNO);
 	LockBuffer(metabuffer, GIN_SHARE);
 	metapage = BufferGetPage(metabuffer);
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 0547038616e..fbca3513e29 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -37,6 +37,7 @@
 #include "utils/memdebug.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
+#include "catalog/storage_gtt.h"
 
 static BTMetaPageData *_bt_getmeta(Relation rel, Buffer metabuf);
 static void _bt_delitems_delete(Relation rel, Buffer buf,
@@ -681,6 +682,15 @@ _bt_getrootheight(Relation rel)
 {
 	BTMetaPageData *metad;
 
+	/*
+	 * An unmaterialized GTT index has no metapage to read; it is empty, so
+	 * its height is zero.  This keeps planning (get_relation_info) from
+	 * materializing per-session storage.
+	 */
+	if (RelationIsGlobalTemp(rel) &&
+		!GttSessionIndexUsable(RelationGetRelid(rel)))
+		return 0;
+
 	if (rel->rd_amcache == NULL)
 	{
 		Buffer		metabuf;
diff --git a/src/backend/catalog/storage_gtt.c b/src/backend/catalog/storage_gtt.c
index e16310a0329..3aa1d20f156 100644
--- a/src/backend/catalog/storage_gtt.c
+++ b/src/backend/catalog/storage_gtt.c
@@ -21,13 +21,18 @@
  */
 #include "postgres.h"
 
+#include "access/amapi.h"
+#include "access/relation.h"
 #include "access/table.h"
 #include "access/tableam.h"
 #include "access/xact.h"
 #include "catalog/heap.h"
+#include "catalog/index.h"
 #include "catalog/pg_tablespace_d.h"
 #include "catalog/storage.h"
 #include "catalog/storage_gtt.h"
+#include "commands/sequence.h"
+#include "commands/tablecmds.h"
 #include "common/hashfn.h"
 #include "miscadmin.h"
 #include "nodes/pg_list.h"
@@ -37,6 +42,7 @@
 #include "storage/smgr.h"
 #include "utils/hsearch.h"
 #include "utils/inval.h"
+#include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
 #include "utils/relcache.h"
@@ -50,6 +56,7 @@
  *     (InvalidSubTransactionId once the entry has survived to top-level
  *     commit)
  *   - storage_subid: subxact that most recently called RelationCreateStorage
+ *   - index_subid: subxact that built the index for this session
  * On subxact or xact abort of a given subid, the corresponding state is
  * reverted.  On subxact commit, the subid is reparented.  See
  * gtt_subxact_callback / gtt_xact_callback.
@@ -57,14 +64,21 @@
 typedef struct GttStorageEntry
 {
 	Oid			relid;			/* GTT's pg_class OID (hash key) */
+	Oid			heap_relid;		/* parent heap for indexes, InvalidOid for
+								 * heap entries themselves */
 	Oid			toast_relid;	/* toast relation for heap entries, InvalidOid
 								 * if none / not a heap */
 	RelFileLocator locator;		/* per-session physical storage location */
 	bool		storage_created;	/* has smgr file been created? */
+	bool		is_index;		/* is this an index relation? */
+	bool		index_built;	/* has index been built in this session? */
+	bool		build_deferred; /* index_build deferred the physical build
+								 * because the parent heap was unmaterialized */
 	bool		on_commit_delete;	/* truncate data on commit? */
 	bool		drop_pending;	/* entry scheduled for drop at xact commit */
 	SubTransactionId create_subid;	/* subxact that added this entry */
 	SubTransactionId storage_subid; /* subxact that created current storage */
+	SubTransactionId index_subid;	/* subxact that built the index */
 } GttStorageEntry;
 
 /* Backend-local hash table: GTT OID -> GttStorageEntry */
@@ -72,13 +86,37 @@ static HTAB *gtt_storage_hash = NULL;
 
 /*
  * True when any entry carries rollback-sensitive state (a valid
- * create_subid/storage_subid, or drop_pending), letting the xact/subxact
- * callbacks skip their full-hash scans in the common case of a transaction
- * that established no such state.  Conservative: it is only cleared once a
- * top-level transaction end has settled every entry.
+ * create_subid/storage_subid/index_subid, or drop_pending), letting the
+ * xact/subxact callbacks skip their full-hash scans in the common case of
+ * a transaction that established no such state.  Conservative: it is only
+ * cleared once a top-level transaction end has settled every entry.
  */
 static bool gtt_xact_state_dirty = false;
 
+/*
+ * Undo log for transactional swaps of a GTT's session-local relfilenumber
+ * (RelationSetNewRelfilenumber on a GTT; reached from TRUNCATE, ALTER
+ * SEQUENCE ... RESTART, and the like).  The file-level work is rolled back
+ * by the regular PendingRelDelete machinery; these records roll back the
+ * session-local mapping and the per-entry state the swap reset.  One record
+ * is pushed per swap, newest first; subxact commit reparents records to the
+ * parent, abort restores and discards them, top-level commit discards them.
+ */
+typedef struct GttSwapUndo
+{
+	Oid			relid;			/* which GTT was swapped */
+	SubTransactionId subid;		/* subxact that performed the swap */
+	RelFileNumber prev_relnumber;	/* mapping to restore on abort */
+	bool		prev_index_built;
+	bool		prev_build_deferred;
+} GttSwapUndo;
+
+/* List of GttSwapUndo *, newest first, allocated in TopMemoryContext */
+static List *gtt_swap_undo = NIL;
+
+/* Guard against recursive index builds */
+static bool gtt_building_index = false;
+
 /* Local function prototypes */
 static void gtt_session_cleanup(int code, Datum arg);
 static void ensure_gtt_hash(void);
@@ -91,7 +129,9 @@ static void gtt_remove_entry(GttStorageEntry *entry);
 static void gtt_revert_storage(GttStorageEntry *entry);
 static void gtt_remove_relids(List *to_remove);
 static void gtt_init_entry(GttStorageEntry *entry, Relation relation);
+static void gtt_build_index_internal(Relation indexRelation, bool force);
 static void gtt_truncate_smgr(GttStorageEntry *entry);
+static void gtt_swap_undo_apply(GttSwapUndo *undo);
 
 /*
  * ensure_gtt_hash
@@ -189,6 +229,29 @@ GttInitSessionStorage(Relation relation)
 		OidIsValid(relation->rd_rel->reltoastrelid))
 		entry->toast_relid = relation->rd_rel->reltoastrelid;
 
+	/*
+	 * RelationBuildLocalRelation (the path used by index_create) leaves
+	 * rd_index NULL: pg_index has not yet been inserted, so the index access
+	 * info cannot be filled in.  Our first call therefore left heap_relid as
+	 * InvalidOid for indexes.  Backfill it now that
+	 * RelationInitIndexAccessInfo has supplied rd_index, so
+	 * PreCommit_gtt_on_commit can find which heap each index belongs to.
+	 */
+	if (entry->is_index && !OidIsValid(entry->heap_relid) &&
+		relation->rd_index != NULL)
+		entry->heap_relid = relation->rd_index->indrelid;
+
+	/*
+	 * Our hash entry tracks this session's current storage for the GTT.  It
+	 * starts out equal to the catalog relfilenode, but a transactional
+	 * TRUNCATE swaps in a new session-local relfilenumber via
+	 * GttSetNewSessionRelfilenumber without touching the shared catalog, so
+	 * the two may legitimately diverge.  CLUSTER, REINDEX, SET TABLESPACE,
+	 * SET LOGGED and heap rewrites (which would rotate the shared relfilenode
+	 * itself) remain blocked for GTTs.
+	 */
+	Assert(RelFileNumberIsValid(entry->locator.relNumber));
+
 	/* Point the relation at our per-session storage */
 	relation->rd_locator = entry->locator;
 	relation->rd_backend = ProcNumberForTempRelations();
@@ -231,10 +294,18 @@ gtt_init_entry(GttStorageEntry *entry, Relation relation)
 	 */
 	entry->locator.relNumber = relation->rd_rel->relfilenode;
 	entry->storage_created = false;
+	entry->is_index = (relation->rd_rel->relkind == RELKIND_INDEX);
+	if (entry->is_index && relation->rd_index != NULL)
+		entry->heap_relid = relation->rd_index->indrelid;
+	else
+		entry->heap_relid = InvalidOid;
+	entry->index_built = false;
+	entry->build_deferred = false;
 	entry->drop_pending = false;
 	entry->create_subid = GetCurrentSubTransactionId();
 	gtt_xact_state_dirty = true;
 	entry->storage_subid = InvalidSubTransactionId;
+	entry->index_subid = InvalidSubTransactionId;
 	entry->on_commit_delete = false;
 	entry->toast_relid = InvalidOid;
 }
@@ -272,6 +343,139 @@ GttEnsureSessionStorage(Relation relation)
 	entry->storage_created = true;
 	entry->storage_subid = GetCurrentSubTransactionId();
 	gtt_xact_state_dirty = true;
+
+	/*
+	 * When a heap materializes -- typically at the top of the first
+	 * heap_insert, before the row is written -- bring its indexes along while
+	 * the heap is still empty.  Opening each index runs the relation_open
+	 * build hook, which now fires because the heap has storage.  Building
+	 * here, rather than when index_insert first touches an index, is what
+	 * keeps a lazy build from indexing the very row whose insertion triggered
+	 * it (which the subsequent aminsert would then insert a second time).
+	 */
+	if (!entry->is_index &&
+		relation->rd_rel->relkind != RELKIND_SEQUENCE)
+	{
+		List	   *indexoids = RelationGetIndexList(relation);
+
+		foreach_oid(idxoid, indexoids)
+		{
+			Relation	idxrel = index_open(idxoid, AccessShareLock);
+
+			index_close(idxrel, NoLock);
+		}
+		list_free(indexoids);
+	}
+}
+
+/*
+ * GttSetNewSessionRelfilenumber
+ *		Point this session's storage mapping for a GTT at a new, empty file.
+ *
+ * Called from RelationSetNewRelfilenumber after it has created the new
+ * per-session file and scheduled the old one for unlink-at-commit.  The
+ * shared pg_class row is deliberately left untouched: other sessions derive
+ * their private storage paths from the catalog relfilenode, so only this
+ * session's mapping changes.
+ *
+ * The swap is transactional: an undo record restores the previous mapping
+ * and the per-entry state reset here if the (sub)transaction aborts, while
+ * the file-level rollback is handled by the PendingRelDelete entries the
+ * caller registered.
+ */
+void
+GttSetNewSessionRelfilenumber(Relation relation, RelFileNumber newrelfilenumber)
+{
+	GttStorageEntry *entry;
+	GttSwapUndo *undo;
+	MemoryContext oldcxt;
+	Oid			relid = RelationGetRelid(relation);
+
+	Assert(RelationIsGlobalTemp(relation));
+
+	if (gtt_storage_hash == NULL)
+		elog(ERROR, "no per-session storage map for global temporary table \"%s\"",
+			 RelationGetRelationName(relation));
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+											HASH_FIND, NULL);
+	if (entry == NULL)
+		elog(ERROR, "no per-session storage entry for global temporary table \"%s\"",
+			 RelationGetRelationName(relation));
+
+	/*
+	 * Swaps only ever apply to materialized storage: TRUNCATE skips
+	 * unmaterialized relations, and sequences are materialized at open. This
+	 * matters because the swap does not register in the sessions registry --
+	 * it relies on GttEnsureSessionStorage having done so.
+	 */
+	Assert(entry->storage_created);
+
+	/* Push the undo record before changing anything. */
+	oldcxt = MemoryContextSwitchTo(TopMemoryContext);
+	undo = palloc_object(GttSwapUndo);
+	undo->relid = relid;
+	undo->subid = GetCurrentSubTransactionId();
+	undo->prev_relnumber = entry->locator.relNumber;
+	undo->prev_index_built = entry->index_built;
+	undo->prev_build_deferred = entry->build_deferred;
+	gtt_swap_undo = lcons(undo, gtt_swap_undo);
+	MemoryContextSwitchTo(oldcxt);
+
+	entry->locator.relNumber = newrelfilenumber;
+	entry->storage_created = true;
+
+	/*
+	 * The new file is empty: indexes must be lazily rebuilt on next access
+	 * (GttBuildIndexIfNeeded).
+	 */
+	if (entry->is_index)
+	{
+		entry->index_built = false;
+
+		/*
+		 * For an index created earlier in this same transaction, the usual
+		 * assumption that index_create() handles the initial build no longer
+		 * holds: that build went into the file being swapped out.  Record the
+		 * build as genuinely outstanding so gtt_build_index_internal rebuilds
+		 * into the new empty file on next access.
+		 */
+		if (relation->rd_createSubid != InvalidSubTransactionId)
+			entry->build_deferred = true;
+	}
+
+	/* Point the open relcache entry at the new storage. */
+	relation->rd_locator = entry->locator;
+	RelationCloseSmgr(relation);
+}
+
+/*
+ * gtt_swap_undo_apply
+ *		Restore the session-local state captured by one swap-undo record.
+ *
+ * The file created by the swap is unlinked, and the pre-swap file's
+ * unlink-at-commit canceled, by the PendingRelDelete machinery; here we
+ * restore the mapping and per-entry bookkeeping to match.
+ */
+static void
+gtt_swap_undo_apply(GttSwapUndo *undo)
+{
+	GttStorageEntry *entry;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &undo->relid,
+											HASH_FIND, NULL);
+	if (entry == NULL)
+		return;					/* entry itself is being removed by abort */
+
+	entry->locator.relNumber = undo->prev_relnumber;
+	entry->index_built = undo->prev_index_built;
+	entry->build_deferred = undo->prev_build_deferred;
+
+	/*
+	 * Refresh the relcache entry so rd_locator points back at the surviving
+	 * pre-swap file on next access.
+	 */
+	RelationCacheInvalidateEntry(undo->relid);
 }
 
 /*
@@ -291,6 +495,34 @@ GttHasSessionStorage(Oid relid)
 	return hash_search(gtt_storage_hash, &relid, HASH_FIND, NULL) != NULL;
 }
 
+/*
+ * GttSessionIndexUsable
+ *		Is this session's copy of a GTT index materialized AND built?
+ *
+ * Readers of index structure that bypass the index AM's own access paths
+ * -- the plan-time metapage peeks (_bt_getrootheight, ginGetStats,
+ * brinGetStats), amcanreturn, and diagnostic readers like pgstattuple --
+ * must treat an index that is materialized but not built as empty rather
+ * than read pages that may not exist.  That state is reachable: a swap
+ * (TRUNCATE) points the mapping at a fresh zero-block file, and the abort
+ * pass that reverts a heap's storage physically empties its surviving
+ * indexes (gtt_truncate_dependents), both clearing index_built so the next
+ * genuine index access rebuilds the structure.  Plan-time readers must not
+ * be the ones to trigger that rebuild.
+ */
+bool
+GttSessionIndexUsable(Oid relid)
+{
+	GttStorageEntry *entry;
+
+	if (gtt_storage_hash == NULL)
+		return false;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+											HASH_FIND, NULL);
+	return entry != NULL && entry->storage_created && entry->index_built;
+}
+
 /*
  * gtt_remove_entry
  *		Release per-session state for a GTT and remove its hash entry.
@@ -346,13 +578,39 @@ GttScheduleDropSessionStorage(Oid relid)
  *		Undo lazily-created storage state on (sub)transaction abort.
  *
  * The files themselves have been unlinked by PendingRelDelete; reset the
- * bookkeeping so the next access re-creates the storage.
+ * bookkeeping so the next access re-creates the storage, and restart the
+ * xid horizon tracking, since no data survives.  The caller must also
+ * remove the relation from the shared sessions registry (we cannot take
+ * the registry LWLock here, mid-hash-scan); with the storage gone there
+ * is no live data left for peer DDL to respect.
  */
 static void
 gtt_revert_storage(GttStorageEntry *entry)
 {
 	entry->storage_created = false;
 	entry->storage_subid = InvalidSubTransactionId;
+
+	/*
+	 * No storage means no index structure: any build this entry ever had
+	 * lived in the files just unlinked.  This must be enforced here rather
+	 * than left to the index_subid bookkeeping, because under nested aborts a
+	 * swap-undo record from an outer subtransaction can re-restore
+	 * index_built=true after an inner subtransaction's abort already cleared
+	 * index_subid -- leaving a "built" index with no file behind it.
+	 */
+	entry->index_built = false;
+	entry->index_subid = InvalidSubTransactionId;
+
+	/*
+	 * Reverting an index's storage also revives its outstanding-build mark:
+	 * if the index was created in this same transaction, the build that
+	 * index_create (or a deferred-build hook) performed went down with the
+	 * reverted file, and gtt_build_index_internal's index_create skip would
+	 * otherwise block the rebuild on the next materialization.  Harmless for
+	 * pre-existing indexes, whose rebuild never consults the mark.
+	 */
+	if (entry->is_index)
+		entry->build_deferred = true;
 }
 
 /*
@@ -415,9 +673,29 @@ gtt_xact_callback(XactEvent event, void *arg)
 	 * don't pay for a full-hash scan at every commit for the rest of the
 	 * session's life just because a GTT was once used.
 	 */
-	if (!gtt_xact_state_dirty)
+	if (!gtt_xact_state_dirty && gtt_swap_undo == NIL)
 		return;
 
+	/*
+	 * Settle the relfilenumber-swap undo log first.  On abort, restore the
+	 * pre-swap state, newest record first, so that the oldest record (the
+	 * state from before the transaction's first swap) lands last; this must
+	 * run before the storage_subid processing below so that storage created
+	 * and then swapped within the aborting transaction still ends up with
+	 * storage_created cleared.  On commit the swaps are final and the records
+	 * are simply discarded.
+	 */
+	if (gtt_swap_undo != NIL)
+	{
+		if (event == XACT_EVENT_ABORT || event == XACT_EVENT_PARALLEL_ABORT)
+		{
+			foreach(lc, gtt_swap_undo)
+				gtt_swap_undo_apply((GttSwapUndo *) lfirst(lc));
+		}
+		list_free_deep(gtt_swap_undo);
+		gtt_swap_undo = NIL;
+	}
+
 	hash_seq_init(&status, gtt_storage_hash);
 	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
 	{
@@ -432,6 +710,7 @@ gtt_xact_callback(XactEvent event, void *arg)
 			{
 				entry->create_subid = InvalidSubTransactionId;
 				entry->storage_subid = InvalidSubTransactionId;
+				entry->index_subid = InvalidSubTransactionId;
 			}
 		}
 		else
@@ -457,6 +736,11 @@ gtt_xact_callback(XactEvent event, void *arg)
 					gtt_revert_storage(entry);
 					to_invalidate = lappend_oid(to_invalidate, entry->relid);
 				}
+				if (entry->index_subid != InvalidSubTransactionId)
+				{
+					entry->index_built = false;
+					entry->index_subid = InvalidSubTransactionId;
+				}
 				entry->drop_pending = false;
 			}
 		}
@@ -503,9 +787,32 @@ gtt_subxact_callback(SubXactEvent event,
 		return;
 
 	/* As in gtt_xact_callback, skip the scans if nothing can need work. */
-	if (!gtt_xact_state_dirty)
+	if (!gtt_xact_state_dirty && gtt_swap_undo == NIL)
 		return;
 
+	/*
+	 * Settle relfilenumber-swap undo records belonging to this subxact: on
+	 * commit reparent them, on abort restore the pre-swap state and discard
+	 * them.  As in gtt_xact_callback, restoring must precede the
+	 * storage_subid processing below.
+	 */
+	foreach(lc, gtt_swap_undo)
+	{
+		GttSwapUndo *undo = (GttSwapUndo *) lfirst(lc);
+
+		if (undo->subid != mySubid)
+			continue;
+
+		if (event == SUBXACT_EVENT_COMMIT_SUB)
+			undo->subid = parentSubid;
+		else
+		{
+			gtt_swap_undo_apply(undo);
+			gtt_swap_undo = foreach_delete_current(gtt_swap_undo, lc);
+			pfree(undo);
+		}
+	}
+
 	hash_seq_init(&status, gtt_storage_hash);
 	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
 	{
@@ -515,6 +822,8 @@ gtt_subxact_callback(SubXactEvent event,
 				entry->create_subid = parentSubid;
 			if (entry->storage_subid == mySubid)
 				entry->storage_subid = parentSubid;
+			if (entry->index_subid == mySubid)
+				entry->index_subid = parentSubid;
 		}
 		else					/* SUBXACT_EVENT_ABORT_SUB */
 		{
@@ -529,6 +838,11 @@ gtt_subxact_callback(SubXactEvent event,
 				gtt_revert_storage(entry);
 				to_invalidate = lappend_oid(to_invalidate, entry->relid);
 			}
+			if (entry->index_subid == mySubid)
+			{
+				entry->index_built = false;
+				entry->index_subid = InvalidSubTransactionId;
+			}
 		}
 	}
 
@@ -611,6 +925,227 @@ gtt_truncate_smgr(GttStorageEntry *entry)
 	smgrtruncate(reln, forks, nforks, old_blocks, new_blocks);
 }
 
+/*
+ * gtt_build_index_internal
+ *		Build a GTT index for this session if it hasn't been built yet.
+ *
+ * Per-session index storage starts out unmaterialized; indexes additionally
+ * need their internal structure initialized (e.g. btree metapage) before
+ * the first access.  When "force" is true (index scans and index inserts),
+ * the index storage is materialized and built unconditionally; when false
+ * (relation_open of the index, for direct-readers like pgstattuple), the
+ * build only proceeds if the parent heap already has materialized storage,
+ * so that merely opening an index -- e.g. the planner's get_relation_info
+ * during EXPLAIN -- materializes nothing.
+ *
+ * The build scans the heap through the ordinary table-AM path, so an
+ * unmaterialized heap simply contributes zero rows (via the zero-blocks
+ * short-circuits) and stays unmaterialized.
+ */
+static void
+gtt_build_index_internal(Relation indexRelation, bool force)
+{
+	GttStorageEntry *entry;
+	Oid			relid = RelationGetRelid(indexRelation);
+	Relation	heapRelation = NULL;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	/* Prevent recursive builds (index_build may trigger index_open) */
+	if (gtt_building_index)
+		return;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+											&relid,
+											HASH_FIND,
+											NULL);
+
+	if (entry == NULL || !entry->is_index)
+		return;
+
+	if (entry->index_built)
+	{
+		/* the structure this flag promises must actually exist */
+		Assert(smgrexists(smgropen(entry->locator,
+								   ProcNumberForTempRelations()),
+						  MAIN_FORKNUM) &&
+			   smgrnblocks(smgropen(entry->locator,
+									ProcNumberForTempRelations()),
+						   MAIN_FORKNUM) > 0);
+		return;
+	}
+
+	/*
+	 * If the index was created in the current transaction, index_create()
+	 * normally handles the initial build via index_build(); skip the build
+	 * here to avoid a "already contains data" error from btbuild (this hook
+	 * fires from relation_open inside index_create, and again from the
+	 * index_open in plan_create_index_workers after index_build has already
+	 * materialized the storage).  The exception is an index whose build is
+	 * recorded as genuinely outstanding (build_deferred): either index_build
+	 * explicitly deferred it because the parent heap was unmaterialized, or a
+	 * TRUNCATE swapped the already-built index to a fresh empty file after
+	 * index_create finished.  In both cases this hook is the only thing that
+	 * will ever (re)build the index.
+	 */
+	if (indexRelation->rd_createSubid != InvalidSubTransactionId &&
+		!entry->build_deferred)
+		return;
+
+	if (!force)
+	{
+		GttStorageEntry *heap_entry;
+
+		if (!OidIsValid(entry->heap_relid))
+			return;
+		heap_entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+													 &entry->heap_relid,
+													 HASH_FIND, NULL);
+		if (heap_entry == NULL || !heap_entry->storage_created)
+			return;
+	}
+
+	/*
+	 * If the index already has blocks (e.g. it was created by this same
+	 * session via CREATE INDEX), it's already been built — just mark it.
+	 * Leave index_subid invalid: the file's content predates the current
+	 * transaction and survives its abort, so this discovery must not be
+	 * rolled back (else an abort of a transaction that merely opened the
+	 * index would force a pointless rebuild).
+	 */
+	if (entry->storage_created &&
+		RelationGetNumberOfBlocks(indexRelation) != 0)
+	{
+		entry->index_built = true;
+		entry->build_deferred = false;
+		return;
+	}
+
+	/* The build is about to write; materialize the index storage. */
+	GttEnsureSessionStorage(indexRelation);
+
+	/*
+	 * Drop any AM-specific cache before rebuilding.  The btree _bt_getroot
+	 * fast path keeps a copy of the metapage in rd_amcache and uses
+	 * btm_fastroot without rereading; if PreCommit_gtt_on_commit truncated
+	 * the index file to zero blocks, that cached block number now points past
+	 * EOF and the next access would fail.  Clearing the cache forces the
+	 * post-rebuild metapage to be reread.
+	 */
+	if (indexRelation->rd_amcache != NULL)
+	{
+		pfree(indexRelation->rd_amcache);
+		indexRelation->rd_amcache = NULL;
+	}
+
+	/*
+	 * Build the index.  Open the heap table, construct the IndexInfo, and
+	 * call ambuild directly.  We use ambuild instead of index_build because
+	 * index_build calls index_update_stats which would update the shared
+	 * pg_class entry — inappropriate for a per-session lazy index build.
+	 *
+	 * For an empty heap, this just initializes the index structure (e.g.
+	 * writes the btree metapage).
+	 *
+	 * Set the guard flag to prevent recursive index builds, since ambuild may
+	 * trigger relcache invalidation that leads back to index_open.
+	 */
+	gtt_building_index = true;
+	PG_TRY();
+	{
+		IndexInfo  *indexInfo;
+
+		heapRelation = table_open(indexRelation->rd_index->indrelid,
+								  AccessShareLock);
+		indexInfo = BuildIndexInfo(indexRelation);
+		indexRelation->rd_indam->ambuild(heapRelation, indexRelation,
+										 indexInfo);
+	}
+	PG_FINALLY();
+	{
+		if (heapRelation != NULL)
+			table_close(heapRelation, AccessShareLock);
+		gtt_building_index = false;
+	}
+	PG_END_TRY();
+
+	/*
+	 * Re-fetch the hash entry after ambuild, because the hash table may have
+	 * been resized during the build (e.g. if opening the heap triggered
+	 * GttInitSessionStorage for other relations).  A concurrent relcache
+	 * invalidation in ambuild could in principle have dropped the entry, so
+	 * cope with NULL rather than asserting.
+	 */
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+											&relid,
+											HASH_FIND,
+											NULL);
+	if (entry != NULL)
+	{
+		entry->index_built = true;
+		entry->build_deferred = false;
+		entry->index_subid = GetCurrentSubTransactionId();
+		gtt_xact_state_dirty = true;
+	}
+}
+
+/*
+ * GttBuildIndexIfNeeded
+ *		Opportunistically build a GTT index at relation open.
+ *
+ * Builds only when the parent heap already has materialized storage, so
+ * opening an index (planning, EXPLAIN) never materializes anything by
+ * itself, while direct readers such as pgstattuple still find a usable
+ * index whenever there is data to inspect.
+ */
+void
+GttBuildIndexIfNeeded(Relation indexRelation)
+{
+	gtt_build_index_internal(indexRelation, false);
+}
+
+/*
+ * GttMarkIndexBuildDeferred
+ *		Record that index_build deferred this index's physical build.
+ *
+ * Called when CREATE INDEX (or any other index_build) runs while the parent
+ * heap is unmaterialized: the catalog work proceeds, but no per-session
+ * structure is built.  The mark tells gtt_build_index_internal that the
+ * build for this same-transaction-created index is genuinely outstanding,
+ * overriding its usual assumption that index_create() will take care of a
+ * just-created index.
+ */
+void
+GttMarkIndexBuildDeferred(Relation indexRelation)
+{
+	GttStorageEntry *entry;
+	Oid			relid = RelationGetRelid(indexRelation);
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+											HASH_FIND, NULL);
+	if (entry != NULL && entry->is_index && !entry->index_built)
+		entry->build_deferred = true;
+}
+
+/*
+ * GttPrepareIndexAccess
+ *		Make a GTT index usable before a scan or insert.
+ *
+ * Index scans and index inserts genuinely access the index structure, so
+ * the per-session index storage is materialized and built here if needed.
+ * The parent heap is not touched: building over an unmaterialized heap
+ * yields an empty (but structurally valid) index.
+ */
+void
+GttPrepareIndexAccess(Relation indexRelation)
+{
+	gtt_build_index_internal(indexRelation, true);
+}
+
 /*
  * PreCommit_gtt_on_commit
  *		Truncate ON COMMIT DELETE ROWS GTTs at commit.
@@ -631,8 +1166,7 @@ PreCommit_gtt_on_commit(void)
 {
 	HASH_SEQ_STATUS status;
 	GttStorageEntry *entry;
-	List	   *toast_relids = NIL;
-	ListCell   *lc;
+	List	   *heap_relids = NIL;
 
 	if (gtt_storage_hash == NULL)
 		return;
@@ -645,45 +1179,143 @@ PreCommit_gtt_on_commit(void)
 	if (!(MyXactFlags & XACT_FLAGS_ACCESSEDTEMPNAMESPACE))
 		return;
 
+	/* First pass: identify ON COMMIT DELETE ROWS heaps to truncate. */
 	hash_seq_init(&status, gtt_storage_hash);
 	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
 	{
+		if (entry->is_index)
+			continue;
 		if (!entry->on_commit_delete || !entry->storage_created)
 			continue;
 
 		/*
 		 * A heap whose main fork is already empty has not been written since
-		 * its last truncation; skip it -- and thereby its toast -- so that an
-		 * idle ON COMMIT DELETE ROWS table costs each commit no more than
-		 * this block-count probe.
+		 * its last truncation; skip it -- and thereby its indexes and toast
+		 * -- so that an idle ON COMMIT DELETE ROWS table costs each commit no
+		 * more than this block-count probe.  This also keeps an index that
+		 * was lazily built against the empty heap intact, rather than
+		 * truncating and rebuilding it at every commit.
 		 */
 		if (smgrnblocks(smgropen(entry->locator, ProcNumberForTempRelations()),
 						MAIN_FORKNUM) == 0)
 			continue;
 
-		gtt_truncate_smgr(entry);
+		heap_relids = lappend_oid(heap_relids, entry->relid);
 
 		/*
 		 * Queue the toast relation too (if this session ever wrote toasted
-		 * values, an entry for it exists).  Truncating just the heap would
-		 * orphan the toast rows for good: nothing else ever deletes them, and
-		 * autovacuum never visits GTTs.
+		 * values, an entry for it exists); its index is then matched by the
+		 * heap_relids check in the second pass like any other index.
 		 */
 		if (OidIsValid(entry->toast_relid))
-			toast_relids = lappend_oid(toast_relids, entry->toast_relid);
+			heap_relids = lappend_oid(heap_relids, entry->toast_relid);
 	}
 
-	foreach(lc, toast_relids)
+	if (heap_relids == NIL)
+		return;
+
+	/*
+	 * Second pass: truncate the heap entries, plus every index entry whose
+	 * parent heap is in heap_relids, and clear the per-session metadata tied
+	 * to each.  Index AM caches (eg btree's rd_amcache) are dropped lazily by
+	 * GttBuildIndexIfNeeded the next time the index is opened, so we don't
+	 * have to invalidate the relcache here.
+	 *
+	 * Toast tables are truncated along with their parents: each heap entry
+	 * records its toast relation's OID (captured from the relcache in
+	 * GttInitSessionStorage), so the toast heap is in heap_relids and its
+	 * index is caught by the matching below, all without any catalog access
+	 * from this commit-time hook.
+	 */
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
 	{
-		Oid			toast_relid = lfirst_oid(lc);
-
-		entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
-												&toast_relid,
-												HASH_FIND, NULL);
-		if (entry != NULL && entry->storage_created)
+		if (entry->is_index)
+		{
+			if (OidIsValid(entry->heap_relid) &&
+				list_member_oid(heap_relids, entry->heap_relid))
+			{
+				gtt_truncate_smgr(entry);
+				entry->index_built = false;
+			}
+		}
+		else if (list_member_oid(heap_relids, entry->relid))
 			gtt_truncate_smgr(entry);
 	}
-	list_free(toast_relids);
+
+	list_free(heap_relids);
+}
+
+/*
+ * GttResetAllSessionData
+ *		Clear this session's data in every global temporary table it has
+ *		touched, for DISCARD TEMP / DISCARD ALL.
+ *
+ * Regular temporary tables are dropped outright by DISCARD TEMP; a GTT's
+ * definition is shared and must survive, but its per-session contents are
+ * session state and are cleared here.  This matters especially for
+ * connection poolers, which rely on DISCARD ALL to prevent one client's
+ * session state from leaking to the next.
+ *
+ * Tables are truncated with the transaction-safe session-storage swap
+ * (GttTruncateInSession), and sequences are reset to their start value
+ * (ResetSequence, which also swaps session storage for a GTT sequence), so
+ * a DISCARD TEMP inside a transaction block is rolled back cleanly if the
+ * transaction aborts -- matching the transactional drop of regular temp
+ * tables.
+ */
+void
+GttResetAllSessionData(void)
+{
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+	List	   *relids = NIL;
+	Relation	rel;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	/* Collect first: truncation work must not run under an active scan. */
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->is_index || !entry->storage_created)
+			continue;
+		relids = lappend_oid(relids, entry->relid);
+	}
+
+	foreach_oid(relid, relids)
+	{
+		switch (get_rel_relkind(relid))
+		{
+			case RELKIND_RELATION:
+				/*
+				 * Same lock TRUNCATE takes; only this session's storage
+				 * is affected, and peers hold no session-lifetime locks
+				 * that could make this wait for their disconnect.
+				 */
+				rel = try_relation_open(relid, AccessExclusiveLock);
+				if (rel == NULL)
+					break;
+				if (RelationIsGlobalTemp(rel))
+					GttTruncateInSession(rel);
+
+				/*
+				 * hold the lock until end of transaction, as TRUNCATE
+				 * does
+				 */
+				relation_close(rel, NoLock);
+				break;
+			case RELKIND_SEQUENCE:
+				if (get_rel_persistence(relid) == RELPERSISTENCE_GLOBAL_TEMP)
+					ResetSequence(relid);
+				break;
+			default:
+				/* toast relations are reset along with their parents */
+				break;
+		}
+	}
+	list_free(relids);
 }
 
 /*
diff --git a/src/backend/commands/discard.c b/src/backend/commands/discard.c
index 17d172df076..4748c364516 100644
--- a/src/backend/commands/discard.c
+++ b/src/backend/commands/discard.c
@@ -15,6 +15,7 @@
 
 #include "access/xact.h"
 #include "catalog/namespace.h"
+#include "catalog/storage_gtt.h"
 #include "commands/async.h"
 #include "commands/discard.h"
 #include "commands/prepare.h"
@@ -47,6 +48,7 @@ DiscardCommand(DiscardStmt *stmt, bool isTopLevel)
 
 		case DISCARD_TEMP:
 			ResetTempTableNamespace();
+			GttResetAllSessionData();
 			break;
 
 		default:
@@ -75,5 +77,6 @@ DiscardAll(bool isTopLevel)
 	LockReleaseAll(USER_LOCKMETHOD, true);
 	ResetPlanCache();
 	ResetTempTableNamespace();
+	GttResetAllSessionData();
 	ResetSequenceCaches();
 }
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 5596d0f5573..75aa1c213ed 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -56,6 +56,7 @@
 #include "catalog/pg_trigger.h"
 #include "catalog/pg_type.h"
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "catalog/storage_xlog.h"
 #include "catalog/toasting.h"
 #include "commands/comment.h"
@@ -1959,6 +1960,97 @@ RangeVarCallbackForDropRelation(const RangeVar *rel, Oid relOid, Oid oldRelOid,
 	}
 }
 
+/*
+ * GttTruncateInSession
+ *		Truncate this session's private storage for a global temporary table.
+ *
+ * Used by TRUNCATE and by DISCARD TEMP/ALL.  The caller must hold
+ * AccessExclusiveLock on the relation.  The truncation is transaction-safe:
+ * for a GTT, RelationSetNewRelfilenumber leaves the shared pg_class row
+ * alone and swaps only the session-local storage mapping (with undo on
+ * abort), so the catalog relfilenode that other sessions derive their
+ * storage paths from is unaffected and a ROLLBACK restores the rows.
+ */
+void
+GttTruncateInSession(Relation rel)
+{
+	Oid			toast_relid;
+	List	   *indexoids;
+	ListCell   *ind;
+
+	/*
+	 * Storage that was never materialized holds nothing to truncate, and the
+	 * swap below must not be the thing that materializes it.
+	 */
+	if (!GttHasSessionStorage(RelationGetRelid(rel)))
+		return;
+
+	/*
+	 * As in the regular TRUNCATE path, this may run in a serializable
+	 * transaction, in which case we must record a rw-conflict in to this
+	 * transaction from each transaction holding a predicate lock on the
+	 * table.
+	 */
+	CheckTableForSerializableConflictIn(rel);
+
+	/*
+	 * Transaction-safe truncation, GTT style: swap this session's private
+	 * storage for new, empty files, so a ROLLBACK restores the rows.  For a
+	 * GTT, RelationSetNewRelfilenumber leaves the shared pg_class row alone
+	 * and changes only the session-local storage mapping, so the catalog
+	 * relfilenode that other sessions derive their storage paths from is
+	 * unaffected.
+	 *
+	 * The indexes cannot go through reindex_relation (REINDEX is disallowed
+	 * for GTTs); instead swap each index's session storage for an empty file
+	 * too and let GttBuildIndexIfNeeded rebuild it on next access.  (Opening
+	 * an index here may lazily build it from the already-swapped heap before
+	 * we swap the index file; that wastes a little work but is rollback-safe,
+	 * because the abort path clears index_built for indexes built in the
+	 * aborted transaction after restoring the swapped-out mapping.)
+	 */
+	RelationSetNewRelfilenumber(rel, rel->rd_rel->relpersistence);
+
+	indexoids = RelationGetIndexList(rel);
+	foreach(ind, indexoids)
+	{
+		Relation	idxrel = relation_open(lfirst_oid(ind),
+										   AccessExclusiveLock);
+
+		/* unmaterialized per-session storage holds nothing to truncate */
+		if (GttHasSessionStorage(RelationGetRelid(idxrel)))
+			RelationSetNewRelfilenumber(idxrel,
+										idxrel->rd_rel->relpersistence);
+		relation_close(idxrel, NoLock);
+	}
+	list_free(indexoids);
+
+	/* The same for the toast table and its index, if any */
+	toast_relid = rel->rd_rel->reltoastrelid;
+	if (OidIsValid(toast_relid) && GttHasSessionStorage(toast_relid))
+	{
+		Relation	toastrel = relation_open(toast_relid,
+											 AccessExclusiveLock);
+
+		RelationSetNewRelfilenumber(toastrel,
+									toastrel->rd_rel->relpersistence);
+
+		indexoids = RelationGetIndexList(toastrel);
+		foreach(ind, indexoids)
+		{
+			Relation	idxrel = relation_open(lfirst_oid(ind),
+											   AccessExclusiveLock);
+
+			if (GttHasSessionStorage(RelationGetRelid(idxrel)))
+				RelationSetNewRelfilenumber(idxrel,
+											idxrel->rd_rel->relpersistence);
+			relation_close(idxrel, NoLock);
+		}
+		list_free(indexoids);
+		table_close(toastrel, NoLock);
+	}
+}
+
 /*
  * ExecuteTruncate
  *		Executes a TRUNCATE command.
@@ -2314,9 +2406,19 @@ ExecuteTruncateGuts(List *explicit_rels,
 		 * a new relfilenumber in the current (sub)transaction, then we can
 		 * just truncate it in-place, because a rollback would cause the whole
 		 * table or the current physical file to be thrown away anyway.
+		 *
+		 * Global temporary tables always go through the session-local swap:
+		 * the in-place path (heap_truncate_one_rel) assumes the relation
+		 * tree's files all exist, but a GTT's toast relation or indexes may
+		 * be unmaterialized -- and a same-transaction TRUNCATE or CREATE
+		 * (which is how rd_newRelfilelocatorSubid/rd_createSubid get set
+		 * here) makes that state likely rather than exotic.
+		 * GttTruncateInSession skips unmaterialized members individually.
 		 */
-		if (rel->rd_createSubid == mySubid ||
-			rel->rd_newRelfilelocatorSubid == mySubid)
+		if (RelationIsGlobalTemp(rel))
+			GttTruncateInSession(rel);
+		else if (rel->rd_createSubid == mySubid ||
+				 rel->rd_newRelfilelocatorSubid == mySubid)
 		{
 			/* Immediate, non-rollbackable truncation is OK */
 			heap_truncate_one_rel(rel);
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 3ffed51f9e9..1b17c0fee30 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -3849,6 +3849,48 @@ RelationSetNewRelfilenumber(Relation relation, char persistence)
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
 				 errmsg("unexpected request for new relfilenumber in binary upgrade mode")));
 
+	/*
+	 * Global temporary tables: the shared pg_class row must keep its
+	 * relfilenode, because every session derives its private storage path
+	 * from it.  Swap only this session's private storage for a new, empty
+	 * file.  The file-level work is transactional through the same
+	 * pending-delete entries as the regular path; the session-local mapping
+	 * is reverted on abort by the undo log in storage_gtt.c.
+	 */
+	if (RelationIsGlobalTemp(relation))
+	{
+		/* GTTs cannot change persistence (ALTER SET LOGGED etc. is blocked) */
+		Assert(persistence == RELPERSISTENCE_GLOBAL_TEMP);
+
+		/* Schedule unlinking of the old per-session storage at commit. */
+		RelationDropStorage(relation);
+
+		newrlocator = relation->rd_locator;
+		newrlocator.relNumber = newrelfilenumber;
+
+		if (RELKIND_HAS_TABLE_AM(relation->rd_rel->relkind))
+		{
+			/* freezeXid/minmulti are tracked per session, not in pg_class */
+			table_relation_set_new_filelocator(relation, &newrlocator,
+											   persistence,
+											   &freezeXid, &minmulti);
+		}
+		else if (RELKIND_HAS_STORAGE(relation->rd_rel->relkind))
+		{
+			SMgrRelation srel;
+
+			srel = RelationCreateStorage(newrlocator, persistence, true);
+			smgrclose(srel);
+		}
+		else
+			elog(ERROR, "relation \"%s\" does not have storage",
+				 RelationGetRelationName(relation));
+
+		GttSetNewSessionRelfilenumber(relation, newrelfilenumber);
+		RelationAssumeNewRelfilelocator(relation);
+		return;
+	}
+
 	/*
 	 * Get a writable copy of the pg_class tuple for the given relation.
 	 */
diff --git a/src/include/catalog/storage_gtt.h b/src/include/catalog/storage_gtt.h
index 9e8b8f1b713..c2df6641600 100644
--- a/src/include/catalog/storage_gtt.h
+++ b/src/include/catalog/storage_gtt.h
@@ -17,8 +17,15 @@
 
 extern void GttInitSessionStorage(Relation relation);
 extern void GttEnsureSessionStorage(Relation relation);
+extern void GttSetNewSessionRelfilenumber(Relation relation,
+										  RelFileNumber newrelfilenumber);
 extern bool GttHasSessionStorage(Oid relid);
+extern bool GttSessionIndexUsable(Oid relid);
 extern void GttScheduleDropSessionStorage(Oid relid);
+extern void GttBuildIndexIfNeeded(Relation indexRelation);
+extern void GttMarkIndexBuildDeferred(Relation indexRelation);
+extern void GttPrepareIndexAccess(Relation indexRelation);
 extern void PreCommit_gtt_on_commit(void);
+extern void GttResetAllSessionData(void);
 
 #endif							/* STORAGE_GTT_H */
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index c3d8518cb62..a94d0c5c239 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -31,6 +31,7 @@ extern ObjectAddress DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 extern TupleDesc BuildDescForRelation(const List *columns);
 
 extern void RemoveRelations(DropStmt *drop);
+extern void GttTruncateInSession(Relation rel);
 
 extern Oid	AlterTableLookupRelation(AlterTableStmt *stmt, LOCKMODE lockmode);
 
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 6ef53535c7e..714a03dd3f3 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1188,6 +1188,7 @@ GroupingSetData
 GroupingSetKind
 GroupingSetsPath
 GttStorageEntry
+GttSwapUndo
 GucAction
 GucBoolAssignHook
 GucBoolCheckHook
-- 
2.43.0



  [text/x-patch] 0005-Global-temporary-tables-disable-parallel-query-and-a.patch (9.1K, ../[email protected]/6-0005-Global-temporary-tables-disable-parallel-query-and-a.patch)
  download | inline diff:
From d67c6d671cfb26198bebca52820d68986f7b27fb Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Fri, 13 Mar 2026 12:18:50 -0400
Subject: [PATCH 05/12] Global temporary tables: disable parallel query and
 autovacuum

GTTs use per-session local buffers that parallel workers cannot access,
for the same reason as regular temporary tables.  Disable parallel
query for GTT scans in set_rel_consider_parallel() and disable parallel
CREATE INDEX in plan_create_index_workers().

Also skip GTTs in autovacuum's table scanning loops, since autovacuum
runs in a separate backend and cannot access any session's local GTT
storage.

The planner-side exclusion only covers GTTs used as parallel
baserels; a function mislabeled PARALLEL SAFE can still reach a GTT
from inside a worker.  GttInitSessionStorage therefore errors out
cleanly in parallel workers (mirroring the temp-table check in
InitLocalBuffers), and in the parallel-mode leader when first-time
storage initialization would be required, instead of tripping
RelationCreateStorage's assertion or scribbling on the leader's temp
namespace from a worker.
---
 src/backend/catalog/storage_gtt.c     | 39 +++++++++++++++++++++++++++
 src/backend/optimizer/path/allpaths.c | 24 ++++++++++-------
 src/backend/optimizer/plan/planner.c  |  7 ++---
 src/backend/optimizer/util/clauses.c  | 20 ++++++++++++++
 src/backend/postmaster/autovacuum.c   | 13 +++++++--
 5 files changed, 89 insertions(+), 14 deletions(-)

diff --git a/src/backend/catalog/storage_gtt.c b/src/backend/catalog/storage_gtt.c
index 3aa1d20f156..bd4540b59b1 100644
--- a/src/backend/catalog/storage_gtt.c
+++ b/src/backend/catalog/storage_gtt.c
@@ -22,6 +22,7 @@
 #include "postgres.h"
 
 #include "access/amapi.h"
+#include "access/parallel.h"
 #include "access/relation.h"
 #include "access/table.h"
 #include "access/tableam.h"
@@ -184,6 +185,33 @@ GttInitSessionStorage(Relation relation)
 	bool		found;
 	Oid			relid = RelationGetRelid(relation);
 
+	/*
+	 * A parallel worker must never create or register per-session storage:
+	 * the storage map is backend-local (a worker cannot see the leader's
+	 * entries or dirty local buffers), so any worker-side materialization
+	 * would corrupt or duplicate the leader's session state.  But a worker
+	 * may legitimately need only the relation's catalog metadata -- e.g.
+	 * pg_get_expr() opens the relation to deparse a column default while
+	 * pg_dump runs under debug_parallel_query.  Point the relcache entry at
+	 * the session-local file in the leader's temp namespace, exactly as an
+	 * untouched GTT looks in the leader, but without recording anything in
+	 * the storage map.  No file is created here; if the leader never
+	 * materialized the relation, reads short-circuit to empty.  The planner
+	 * never makes a GTT a parallel baserel and DML is never parallelized, so
+	 * a worker never actually scans or writes one.
+	 */
+	if (IsParallelWorker())
+	{
+		if (OidIsValid(relation->rd_rel->reltablespace))
+			relation->rd_locator.spcOid = relation->rd_rel->reltablespace;
+		else
+			relation->rd_locator.spcOid = MyDatabaseTableSpace;
+		relation->rd_locator.dbOid = MyDatabaseId;
+		relation->rd_locator.relNumber = relation->rd_rel->relfilenode;
+		relation->rd_backend = ProcNumberForTempRelations();
+		return;
+	}
+
 	ensure_gtt_hash();
 
 	entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
@@ -339,6 +367,17 @@ GttEnsureSessionStorage(Relation relation)
 	if (entry->storage_created)
 		return;
 
+	/*
+	 * RelationCreateStorage cannot run in parallel mode (it couldn't update
+	 * pendingSyncHash), and a worker must never materialize state in the
+	 * leader's temp namespace; relcache builds in workers are already
+	 * rejected in GttInitSessionStorage.
+	 */
+	if (IsInParallelMode())
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_TRANSACTION_STATE),
+				errmsg("cannot initialize global temporary table storage during a parallel operation"));
+
 	RelationCreateStorage(entry->locator, RELPERSISTENCE_GLOBAL_TEMP, true);
 	entry->storage_created = true;
 	entry->storage_subid = GetCurrentSubTransactionId();
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index c134594a21a..c40b71c902d 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -664,16 +664,22 @@ set_rel_consider_parallel(PlannerInfo *root, RelOptInfo *rel,
 
 			/*
 			 * Currently, parallel workers can't access the leader's temporary
-			 * tables.  We could possibly relax this if we wrote all of its
-			 * local buffers at the start of the query and made no changes
-			 * thereafter (maybe we could allow hint bit changes), and if we
-			 * taught the workers to read them.  Writing a large number of
-			 * temporary buffers could be expensive, though, and we don't have
-			 * the rest of the necessary infrastructure right now anyway.  So
-			 * for now, bail out if we see a temporary table.
+			 * tables, nor a global temporary table's per-session data.  We
+			 * could possibly relax this if we wrote all of its local buffers
+			 * at the start of the query and made no changes thereafter (maybe
+			 * we could allow hint bit changes), and if we taught the workers
+			 * to read them.  Writing a large number of temporary buffers
+			 * could be expensive, though, and we don't have the rest of the
+			 * necessary infrastructure right now anyway.  So for now, bail
+			 * out if we see a temporary or global temporary table.
 			 */
-			if (get_rel_persistence(rte->relid) == RELPERSISTENCE_TEMP)
-				return;
+			{
+				char		relpersist = get_rel_persistence(rte->relid);
+
+				if (relpersist == RELPERSISTENCE_TEMP ||
+					relpersist == RELPERSISTENCE_GLOBAL_TEMP)
+					return;
+			}
 
 			/*
 			 * Table sampling can be pushed down to workers if the sample
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index f4689e7c9f8..964dc4a0fa0 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -7347,11 +7347,12 @@ plan_create_index_workers(Oid tableOid, Oid indexOid)
 	/*
 	 * Determine if it's safe to proceed.
 	 *
-	 * Currently, parallel workers can't access the leader's temporary tables.
-	 * Furthermore, any index predicate or index expressions must be parallel
-	 * safe.
+	 * Currently, parallel workers can't access the leader's temporary tables,
+	 * nor a global temporary table's per-session data.  Furthermore, any
+	 * index predicate or index expressions must be parallel safe.
 	 */
 	if (heap->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
+		RelationIsGlobalTemp(heap) ||
 		!is_parallel_safe(root, (Node *) RelationGetIndexExpressions(index)) ||
 		!is_parallel_safe(root, (Node *) RelationGetIndexPredicate(index)))
 	{
diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index 01997e22266..8d98d5f8b43 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -965,6 +965,7 @@ max_parallel_hazard_walker(Node *node, max_parallel_hazard_context *context)
 	else if (IsA(node, Query))
 	{
 		Query	   *query = (Query *) node;
+		ListCell   *lc;
 
 		/* SELECT FOR UPDATE/SHARE must be treated as unsafe */
 		if (query->rowMarks != NULL)
@@ -973,6 +974,25 @@ max_parallel_hazard_walker(Node *node, max_parallel_hazard_context *context)
 			return true;
 		}
 
+		/*
+		 * A global temporary table has per-session, backend-local storage
+		 * that parallel workers cannot see, and that storage may have to be
+		 * created on first access -- which is impossible in parallel mode.
+		 * Referencing one therefore makes the query parallel-unsafe, so that
+		 * parallel mode is never imposed on it (e.g. by debug_parallel_query).
+		 */
+		foreach(lc, query->rtable)
+		{
+			RangeTblEntry *rte = lfirst_node(RangeTblEntry, lc);
+
+			if (rte->rtekind == RTE_RELATION &&
+				get_rel_persistence(rte->relid) == RELPERSISTENCE_GLOBAL_TEMP)
+			{
+				context->max_hazard = PROPARALLEL_UNSAFE;
+				return true;
+			}
+		}
+
 		/* Recurse into subselects */
 		return query_tree_walker(query,
 								 max_parallel_hazard_walker,
diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index e9aaf24c1be..7452518c386 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -2073,6 +2073,13 @@ do_autovacuum(void)
 			continue;
 		}
 
+		/*
+		 * Global temporary tables have per-session local storage that
+		 * autovacuum cannot access.  Skip them.
+		 */
+		if (classForm->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			continue;
+
 		/* Fetch reloptions and the pgstat entry for this table */
 		relopts = extract_autovac_opts(tuple, pg_class_desc);
 
@@ -2147,9 +2154,11 @@ do_autovacuum(void)
 		AutoVacuumScores scores;
 
 		/*
-		 * We cannot safely process other backends' temp tables, so skip 'em.
+		 * We cannot safely process other backends' temp tables or GTTs (which
+		 * have per-session local storage), so skip them.
 		 */
-		if (classForm->relpersistence == RELPERSISTENCE_TEMP)
+		if (classForm->relpersistence == RELPERSISTENCE_TEMP ||
+			classForm->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
 			continue;
 
 		relid = classForm->oid;
-- 
2.43.0



  [text/x-patch] 0006-Global-temporary-tables-per-session-ANALYZE-statisti.patch (62.8K, ../[email protected]/7-0006-Global-temporary-tables-per-session-ANALYZE-statisti.patch)
  download | inline diff:
From 04826695007a28f3ea14981396eaf442dcd5583a Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Sun, 15 Mar 2026 10:18:26 -0400
Subject: [PATCH 06/12] Global temporary tables: per-session ANALYZE statistics

GTT data is per-session, so the shared pg_class.relpages/reltuples
and pg_statistic rows are meaningless for query planning -- they
reflect one session's sample and mislead every other session.  This
commit adds per-session statistics that ANALYZE populates in
backend-local memory and the planner consults via dedicated lookup
wrappers.

Relation-level stats (relpages, reltuples, relallvisible) are stored
in the existing per-session GTT storage hash via
GttUpdateSessionStats().  plancat.c's estimate_rel_size checks for
per-session stats first and uses them for tuple density estimation,
falling back to the standard attribute-width-based heuristic when
ANALYZE has not been run in this session.

Column-level stats (histograms, MCVs, distinct counts) are stored
as pg_statistic-format HeapTuples in a second backend-local hash
(gtt_colstats_hash), keyed by (relid, attnum, inh).  update_attstats
grows an is_gtt flag: when set, it builds the tuple via a new
build_statstuple() helper and stores it through
GttStoreSessionColumnStats() instead of writing pg_statistic.

The planner read path is changed at 8 call sites (selfuncs.c,
nodeHash.c, lsyscache.c) to use a new SearchStatsWithGtt() wrapper
that checks the per-session hash before falling through to the
pg_statistic syscache.

PreCommit_gtt_on_commit() is extended with a stats_valid reset on
the heap entries that ON COMMIT DELETE ROWS truncated, and calls
gtt_reset_colstats_for_rel() to clear the per-column hash for each
of those relations.  TRUNCATE's session-storage swap resets the
relation-level stats (rolled back with the rest of the swap-undo
record on abort) but keeps column-level stats, matching the behavior
of pg_statistic for regular tables.  Per-session stats do not survive
session end.

ANALYZE builds the per-session stats tuple in the transient context
and copies only the finished tuple into TopMemoryContext, so the
sizable MCV/histogram intermediates are reclaimed with the
transaction instead of accumulating for the backend's life.

index_update_stats() never writes page/tuple counts to the shared
pg_class row for a GTT, for the same reason as vac_update_relstats;
relhasindex, a property of the shared catalog definition, is still
maintained.

Two new SRF functions provide visibility into per-session stats:
- pg_gtt_relstats(regclass) -- relation-level stats
- pg_gtt_colstats(regclass) -- column-level stats (pg_stats-like format)
Both accept NULL to show all GTTs and require the caller to have
SELECT privilege on each relation (and, for colstats, on the
attribute) before its stats are returned, matching pg_stats.  The
regclass DEFAULT NULL is added via system_functions.sql since
regclass is not in the bootstrap TypInfo.

Per-session statistics are transactional: the entry records the
(sub)transaction that last wrote them (stats_subid, reparented on
subtransaction commit like the other subids), and the abort paths
invalidate relation- and column-level stats written by an aborted
(sub)transaction.  Without this, BEGIN; INSERT ...; ANALYZE; ROLLBACK
would leave the planner estimating against row counts for data that no
longer exists, with no autovacuum to ever correct it.
---
 src/backend/catalog/index.c              |  52 ++
 src/backend/catalog/storage_gtt.c        | 771 ++++++++++++++++++++++-
 src/backend/catalog/system_functions.sql |  38 ++
 src/backend/commands/analyze.c           | 302 +++++----
 src/backend/executor/nodeHash.c          |  15 +-
 src/backend/optimizer/util/plancat.c     |  60 ++
 src/backend/utils/adt/selfuncs.c         |  54 +-
 src/backend/utils/cache/lsyscache.c      |  12 +-
 src/include/catalog/pg_proc.dat          |  28 +
 src/include/catalog/storage_gtt.h        |  16 +
 src/tools/pgindent/typedefs.list         |   2 +
 11 files changed, 1194 insertions(+), 156 deletions(-)

diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 9407c357f27..cff8d39fb44 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2343,6 +2343,16 @@ index_drop(Oid indexId, bool concurrent, bool concurrent_lock_mode)
 	if (RELKIND_HAS_STORAGE(userIndexRelation->rd_rel->relkind))
 		RelationDropStorage(userIndexRelation);
 
+	/*
+	 * For a GTT index, also retire this session's storage-map entry (and its
+	 * registry row) at commit, exactly as heap_drop_with_catalog does for
+	 * tables.  Without this the entry would linger with storage_created set
+	 * but no file behind it, tripping any later pass that walks materialized
+	 * entries.
+	 */
+	if (RelationIsGlobalTemp(userIndexRelation))
+		GttScheduleDropSessionStorage(indexId);
+
 	/* ensure that stats are dropped if transaction commits */
 	pgstat_drop_relation(userIndexRelation);
 
@@ -2880,6 +2890,18 @@ index_update_stats(Relation rel,
 			update_stats = false;
 	}
 
+	/*
+	 * For a global temporary table, the page/tuple counts describe this
+	 * session's private data and must never be written to the shared pg_class
+	 * row, which is common to all sessions (cf. vac_update_relstats).  Just
+	 * drop them: per-session statistics are established by ANALYZE, and until
+	 * then the planner estimates from the session storage's actual size, as
+	 * for any fresh table.  relhasindex is a property of the shared catalog
+	 * definition, so fall through to update it below.
+	 */
+	if (RelationIsGlobalTemp(rel))
+		update_stats = false;
+
 	/*
 	 * Finish I/O and visibility map buffer locks before
 	 * systable_inplace_update_begin() locks the pg_class buffer.  The rd_rel
@@ -3038,6 +3060,36 @@ index_build(Relation heapRelation,
 	Assert(indexRelation->rd_indam->ambuild);
 	Assert(indexRelation->rd_indam->ambuildempty);
 
+	/*
+	 * A GTT index's per-session storage is created lazily.  If the parent
+	 * heap has no per-session storage yet, defer the physical build entirely:
+	 * there is nothing to index, and materializing the index now would let a
+	 * later transaction's rollback strand its entries (the heap file is
+	 * unlinked on abort, but an index file committed earlier is not -- though
+	 * gtt_truncate_dependents also backstops that case). The catalog work has
+	 * already happened; the per-session structure is built when the heap
+	 * materializes, or at the first index scan. Otherwise (heap has storage),
+	 * materialize the index and build for real -- covering CREATE INDEX on a
+	 * populated GTT and the in-place truncation of a same-transaction-created
+	 * GTT.
+	 */
+	if (RelationIsGlobalTemp(indexRelation))
+	{
+		if (!GttHasSessionStorage(RelationGetRelid(heapRelation)))
+		{
+			/*
+			 * Even a deferred build must mark the shared catalog: without
+			 * relhasindex the planner never looks at pg_index, in every
+			 * session.  (index_update_stats writes no page/tuple counts for a
+			 * GTT; relhasindex is shared-definition state.)
+			 */
+			GttMarkIndexBuildDeferred(indexRelation);
+			index_update_stats(heapRelation, true, -1);
+			return;
+		}
+		GttEnsureSessionStorage(indexRelation);
+	}
+
 	/*
 	 * Determine worker process details for parallel CREATE INDEX.  Currently,
 	 * only btree, GIN, and BRIN have support for parallel builds.
diff --git a/src/backend/catalog/storage_gtt.c b/src/backend/catalog/storage_gtt.c
index bd4540b59b1..fc6feb42f6d 100644
--- a/src/backend/catalog/storage_gtt.c
+++ b/src/backend/catalog/storage_gtt.c
@@ -22,6 +22,7 @@
 #include "postgres.h"
 
 #include "access/amapi.h"
+#include "access/htup_details.h"
 #include "access/parallel.h"
 #include "access/relation.h"
 #include "access/table.h"
@@ -29,24 +30,33 @@
 #include "access/xact.h"
 #include "catalog/heap.h"
 #include "catalog/index.h"
+#include "catalog/pg_attribute.h"
+#include "catalog/pg_statistic.h"
 #include "catalog/pg_tablespace_d.h"
 #include "catalog/storage.h"
 #include "catalog/storage_gtt.h"
 #include "commands/sequence.h"
 #include "commands/tablecmds.h"
 #include "common/hashfn.h"
+#include "funcapi.h"
 #include "miscadmin.h"
 #include "nodes/pg_list.h"
 #include "storage/ipc.h"
 #include "storage/bufmgr.h"
 #include "storage/procnumber.h"
 #include "storage/smgr.h"
+#include "utils/acl.h"
+#include "utils/array.h"
+#include "utils/fmgroids.h"
+#include "utils/builtins.h"
+#include "utils/tuplestore.h"
 #include "utils/hsearch.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
 #include "utils/relcache.h"
+#include "utils/syscache.h"
 
 /*
  * Per-session state for a single global temporary table.
@@ -58,6 +68,7 @@
  *     commit)
  *   - storage_subid: subxact that most recently called RelationCreateStorage
  *   - index_subid: subxact that built the index for this session
+ *   - stats_subid: subxact that last wrote per-session statistics
  * On subxact or xact abort of a given subid, the corresponding state is
  * reverted.  On subxact commit, the subid is reparented.  See
  * gtt_subxact_callback / gtt_xact_callback.
@@ -80,6 +91,13 @@ typedef struct GttStorageEntry
 	SubTransactionId create_subid;	/* subxact that added this entry */
 	SubTransactionId storage_subid; /* subxact that created current storage */
 	SubTransactionId index_subid;	/* subxact that built the index */
+	SubTransactionId stats_subid;	/* subxact that last wrote session stats */
+
+	/* Per-session relation statistics (set by ANALYZE) */
+	bool		stats_valid;	/* has ANALYZE been run in this session? */
+	BlockNumber relpages;		/* per-session page count */
+	float4		reltuples;		/* per-session tuple count */
+	BlockNumber relallvisible;	/* per-session all-visible pages */
 } GttStorageEntry;
 
 /* Backend-local hash table: GTT OID -> GttStorageEntry */
@@ -110,17 +128,55 @@ typedef struct GttSwapUndo
 	RelFileNumber prev_relnumber;	/* mapping to restore on abort */
 	bool		prev_index_built;
 	bool		prev_build_deferred;
+	bool		prev_stats_valid;
+	BlockNumber prev_relpages;
+	float4		prev_reltuples;
+	BlockNumber prev_relallvisible;
 } GttSwapUndo;
 
 /* List of GttSwapUndo *, newest first, allocated in TopMemoryContext */
 static List *gtt_swap_undo = NIL;
 
+/*
+ * Per-session column-level statistics for global temporary tables.
+ *
+ * Column stats (histograms, MCVs, distinct counts, etc.) are stored as
+ * pg_statistic-format HeapTuples in a separate backend-local hash table,
+ * keyed by (relid, attnum, inh).  This parallels the relation-level stats
+ * (relpages/reltuples) stored in GttStorageEntry above.
+ *
+ * The key has trailing alignment padding that HASH_BLOBS hashes verbatim,
+ * so all key instances must be zero-initialized before the fields are set.
+ * Always build keys via init_colstats_key() rather than by hand.
+ */
+typedef struct GttColStatsKey
+{
+	Oid			relid;			/* relation OID */
+	AttrNumber	attnum;			/* attribute number */
+	bool		inh;			/* include inheritance children? */
+} GttColStatsKey;
+
+typedef struct GttColStatsEntry
+{
+	GttColStatsKey key;			/* hash key — must be first */
+	HeapTuple	statsTuple;		/* pg_statistic-format tuple in
+								 * TopMemoryContext */
+} GttColStatsEntry;
+
+/* Backend-local hash table: (relid, attnum, inh) -> GttColStatsEntry */
+static HTAB *gtt_colstats_hash = NULL;
+
 /* Guard against recursive index builds */
 static bool gtt_building_index = false;
 
 /* Local function prototypes */
 static void gtt_session_cleanup(int code, Datum arg);
 static void ensure_gtt_hash(void);
+static void ensure_gtt_colstats_hash(void);
+static void init_colstats_key(GttColStatsKey *key, Oid relid,
+							  AttrNumber attnum, bool inh);
+static void gtt_reset_colstats_for_rel(Oid relid);
+static char *format_stats_values_as_text(AttStatsSlot *sslot);
 static void gtt_xact_callback(XactEvent event, void *arg);
 static void gtt_subxact_callback(SubXactEvent event,
 								 SubTransactionId mySubid,
@@ -166,6 +222,48 @@ ensure_gtt_hash(void)
 	RegisterSubXactCallback(gtt_subxact_callback, NULL);
 }
 
+/*
+ * ensure_gtt_colstats_hash
+ *		Create the backend-local column stats hash table on first use.
+ *
+ * This is separate from ensure_gtt_hash() so the column stats hash is only
+ * created when actually needed (during ANALYZE or planner lookup).
+ */
+static void
+ensure_gtt_colstats_hash(void)
+{
+	HASHCTL		hashctl;
+
+	if (gtt_colstats_hash != NULL)
+		return;
+
+	hashctl.keysize = sizeof(GttColStatsKey);
+	hashctl.entrysize = sizeof(GttColStatsEntry);
+	hashctl.hcxt = TopMemoryContext;
+	gtt_colstats_hash = hash_create("GTT column stats hash",
+									64, /* initial size */
+									&hashctl,
+									HASH_ELEM | HASH_BLOBS | HASH_CONTEXT);
+}
+
+/*
+ * init_colstats_key
+ *		Build a GttColStatsKey with deterministic byte contents.
+ *
+ * HASH_BLOBS hashes the key byte-for-byte, including any trailing
+ * alignment padding the compiler may insert after the last field.
+ * Zero the whole struct first so equivalent (relid, attnum, inh) triples
+ * always produce the same hash.
+ */
+static void
+init_colstats_key(GttColStatsKey *key, Oid relid, AttrNumber attnum, bool inh)
+{
+	memset(key, 0, sizeof(*key));
+	key->relid = relid;
+	key->attnum = attnum;
+	key->inh = inh;
+}
+
 /*
  * GttInitSessionStorage
  *		Ensure per-session local storage exists for the given GTT relation.
@@ -334,8 +432,20 @@ gtt_init_entry(GttStorageEntry *entry, Relation relation)
 	gtt_xact_state_dirty = true;
 	entry->storage_subid = InvalidSubTransactionId;
 	entry->index_subid = InvalidSubTransactionId;
+	entry->stats_subid = InvalidSubTransactionId;
+	entry->stats_valid = false;
+	entry->relpages = 0;
+	entry->reltuples = 0;
+	entry->relallvisible = 0;
 	entry->on_commit_delete = false;
 	entry->toast_relid = InvalidOid;
+
+	/*
+	 * Discard any column statistics recorded under this OID: when the entry
+	 * is being refreshed after OID recycling, they describe a different,
+	 * dropped relation.  (For a brand-new entry this is a no-op.)
+	 */
+	gtt_reset_colstats_for_rel(entry->relid);
 }
 
 /*
@@ -458,6 +568,10 @@ GttSetNewSessionRelfilenumber(Relation relation, RelFileNumber newrelfilenumber)
 	undo->prev_relnumber = entry->locator.relNumber;
 	undo->prev_index_built = entry->index_built;
 	undo->prev_build_deferred = entry->build_deferred;
+	undo->prev_stats_valid = entry->stats_valid;
+	undo->prev_relpages = entry->relpages;
+	undo->prev_reltuples = entry->reltuples;
+	undo->prev_relallvisible = entry->relallvisible;
 	gtt_swap_undo = lcons(undo, gtt_swap_undo);
 	MemoryContextSwitchTo(oldcxt);
 
@@ -466,7 +580,9 @@ GttSetNewSessionRelfilenumber(Relation relation, RelFileNumber newrelfilenumber)
 
 	/*
 	 * The new file is empty: indexes must be lazily rebuilt on next access
-	 * (GttBuildIndexIfNeeded).
+	 * (GttBuildIndexIfNeeded), and previous ANALYZE results no longer apply.
+	 * Column-level statistics are left alone, matching the behavior of
+	 * TRUNCATE on regular tables, which does not clear pg_statistic.
 	 */
 	if (entry->is_index)
 	{
@@ -482,6 +598,10 @@ GttSetNewSessionRelfilenumber(Relation relation, RelFileNumber newrelfilenumber)
 		if (relation->rd_createSubid != InvalidSubTransactionId)
 			entry->build_deferred = true;
 	}
+	entry->stats_valid = false;
+	entry->relpages = 0;
+	entry->reltuples = 0;
+	entry->relallvisible = 0;
 
 	/* Point the open relcache entry at the new storage. */
 	relation->rd_locator = entry->locator;
@@ -509,6 +629,10 @@ gtt_swap_undo_apply(GttSwapUndo *undo)
 	entry->locator.relNumber = undo->prev_relnumber;
 	entry->index_built = undo->prev_index_built;
 	entry->build_deferred = undo->prev_build_deferred;
+	entry->stats_valid = undo->prev_stats_valid;
+	entry->relpages = undo->prev_relpages;
+	entry->reltuples = undo->prev_reltuples;
+	entry->relallvisible = undo->prev_relallvisible;
 
 	/*
 	 * Refresh the relcache entry so rd_locator points back at the surviving
@@ -577,6 +701,9 @@ gtt_remove_entry(GttStorageEntry *entry)
 {
 	Oid			relid = entry->relid;
 
+	/* Discard any per-session column statistics for this relation */
+	gtt_reset_colstats_for_rel(relid);
+
 	hash_search(gtt_storage_hash, &relid, HASH_REMOVE, NULL);
 }
 
@@ -750,6 +877,7 @@ gtt_xact_callback(XactEvent event, void *arg)
 				entry->create_subid = InvalidSubTransactionId;
 				entry->storage_subid = InvalidSubTransactionId;
 				entry->index_subid = InvalidSubTransactionId;
+				entry->stats_subid = InvalidSubTransactionId;
 			}
 		}
 		else
@@ -780,6 +908,20 @@ gtt_xact_callback(XactEvent event, void *arg)
 					entry->index_built = false;
 					entry->index_subid = InvalidSubTransactionId;
 				}
+				if (entry->stats_subid != InvalidSubTransactionId)
+				{
+					/*
+					 * Session statistics written by the aborted transaction
+					 * describe rolled-back data; throw them away (column
+					 * stats too -- the previous tuples were freed when the
+					 * aborted ANALYZE replaced them, so there is nothing to
+					 * restore).  The planner falls back to size-based
+					 * estimation, which is right for the surviving state.
+					 */
+					entry->stats_valid = false;
+					entry->stats_subid = InvalidSubTransactionId;
+					gtt_reset_colstats_for_rel(entry->relid);
+				}
 				entry->drop_pending = false;
 			}
 		}
@@ -863,6 +1005,8 @@ gtt_subxact_callback(SubXactEvent event,
 				entry->storage_subid = parentSubid;
 			if (entry->index_subid == mySubid)
 				entry->index_subid = parentSubid;
+			if (entry->stats_subid == mySubid)
+				entry->stats_subid = parentSubid;
 		}
 		else					/* SUBXACT_EVENT_ABORT_SUB */
 		{
@@ -882,6 +1026,13 @@ gtt_subxact_callback(SubXactEvent event,
 				entry->index_built = false;
 				entry->index_subid = InvalidSubTransactionId;
 			}
+			if (entry->stats_subid == mySubid)
+			{
+				/* see gtt_xact_callback */
+				entry->stats_valid = false;
+				entry->stats_subid = InvalidSubTransactionId;
+				gtt_reset_colstats_for_rel(entry->relid);
+			}
 		}
 	}
 
@@ -1185,6 +1336,285 @@ GttPrepareIndexAccess(Relation indexRelation)
 	gtt_build_index_internal(indexRelation, true);
 }
 
+/*
+ * GttGetSessionStats
+ *		Retrieve per-session relation statistics for a GTT.
+ *
+ * Returns true if per-session statistics are available (i.e. ANALYZE has
+ * been run on this GTT in this session), filling in the output parameters.
+ * Returns false if no per-session stats exist, in which case the planner
+ * should fall back to default estimation.
+ */
+bool
+GttGetSessionStats(Oid relid, BlockNumber *relpages, double *reltuples,
+				   BlockNumber *relallvisible)
+{
+	GttStorageEntry *entry;
+
+	if (gtt_storage_hash == NULL)
+		return false;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+											&relid,
+											HASH_FIND,
+											NULL);
+	if (entry == NULL || !entry->stats_valid)
+		return false;
+
+	*relpages = entry->relpages;
+	*reltuples = (double) entry->reltuples;
+	*relallvisible = entry->relallvisible;
+	return true;
+}
+
+/*
+ * GttUpdateSessionStats
+ *		Store per-session relation statistics for a GTT.
+ *
+ * Called from ANALYZE to record relpages/reltuples/relallvisible in the
+ * per-session hash instead of writing to the shared pg_class row.
+ */
+void
+GttUpdateSessionStats(Oid relid, BlockNumber relpages, double reltuples,
+					  BlockNumber relallvisible)
+{
+	GttStorageEntry *entry;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+											&relid,
+											HASH_FIND,
+											NULL);
+	if (entry == NULL)
+		return;
+
+	entry->stats_valid = true;
+	entry->relpages = relpages;
+	entry->reltuples = (float4) reltuples;
+	entry->relallvisible = relallvisible;
+
+	/*
+	 * Unlike pg_class/pg_statistic writes, these survive a transaction abort
+	 * unless we act: remember the writing subxact so the abort paths can
+	 * invalidate stats that describe rolled-back data.
+	 */
+	entry->stats_subid = GetCurrentSubTransactionId();
+	gtt_xact_state_dirty = true;
+}
+
+/*
+ * GttResetSessionStats
+ *		Invalidate per-session stats for a GTT after TRUNCATE.
+ *
+ * After truncation, the previous ANALYZE statistics are no longer valid.
+ * The planner will fall back to default estimation based on actual page
+ * count until ANALYZE is run again.
+ */
+void
+GttResetSessionStats(Oid relid)
+{
+	GttStorageEntry *entry;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash,
+											&relid,
+											HASH_FIND,
+											NULL);
+	if (entry != NULL)
+		entry->stats_valid = false;
+
+	/* Also clear any per-session column statistics */
+	gtt_reset_colstats_for_rel(relid);
+}
+
+/*
+ * GttStoreSessionColumnStats
+ *		Store a per-session column statistics tuple for a GTT.
+ *
+ * The tuple must be a pg_statistic-format HeapTuple allocated in
+ * TopMemoryContext.  If an entry already exists for this (relid, attnum, inh),
+ * the old tuple is freed and replaced.
+ */
+void
+GttStoreSessionColumnStats(Oid relid, AttrNumber attnum, bool inh,
+						   HeapTuple tuple)
+{
+	GttColStatsKey key;
+	GttColStatsEntry *entry;
+	GttStorageEntry *rel_entry;
+	bool		found;
+
+	ensure_gtt_colstats_hash();
+
+	init_colstats_key(&key, relid, attnum, inh);
+
+	entry = (GttColStatsEntry *) hash_search(gtt_colstats_hash,
+											 &key,
+											 HASH_ENTER,
+											 &found);
+	if (found && entry->statsTuple != NULL)
+		heap_freetuple(entry->statsTuple);
+
+	entry->statsTuple = tuple;
+
+	/*
+	 * Mark the relation-level entry so an abort of the writing (sub)xact
+	 * invalidates the column stats along with the relation stats; see
+	 * GttUpdateSessionStats.
+	 */
+	if (gtt_storage_hash != NULL)
+	{
+		rel_entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+													HASH_FIND, NULL);
+		if (rel_entry != NULL)
+		{
+			rel_entry->stats_subid = GetCurrentSubTransactionId();
+			gtt_xact_state_dirty = true;
+		}
+	}
+}
+
+/*
+ * GttSearchColumnStats
+ *		Look up per-session column statistics for a GTT column.
+ *
+ * Returns the stored pg_statistic-format HeapTuple, or NULL if no per-session
+ * stats exist for this column.  The caller must NOT free the returned tuple;
+ * it is owned by the hash table.
+ */
+HeapTuple
+GttSearchColumnStats(Oid relid, AttrNumber attnum, bool inh)
+{
+	GttColStatsKey key;
+	GttColStatsEntry *entry;
+
+	if (gtt_colstats_hash == NULL)
+		return NULL;
+
+	init_colstats_key(&key, relid, attnum, inh);
+
+	entry = (GttColStatsEntry *) hash_search(gtt_colstats_hash,
+											 &key,
+											 HASH_FIND,
+											 NULL);
+	if (entry != NULL)
+		return entry->statsTuple;
+
+	return NULL;
+}
+
+/*
+ * GttReleaseColumnStats
+ *		No-op freefunc for per-session GTT column statistics tuples.
+ *
+ * The tuple is owned by gtt_colstats_hash and must not be freed by the
+ * planner.  This function is used as the VariableStatData.freefunc callback.
+ */
+void
+GttReleaseColumnStats(HeapTuple tuple)
+{
+	/* No-op: tuple lives in gtt_colstats_hash in TopMemoryContext */
+}
+
+/*
+ * SearchStats
+ *		Look up column statistics, checking per-session GTT stats if requested.
+ *
+ * Checks the pg_statistic syscache first.  If include_gtt is true and no
+ * shared stats are found, falls back to per-session GTT statistics.
+ * Sets *freefunc to the appropriate release function for the returned tuple.
+ */
+HeapTuple
+SearchStats(Oid relid, AttrNumber attnum, bool inh,
+			bool include_gtt,
+			void (**freefunc) (HeapTuple))
+{
+	HeapTuple	tuple;
+
+	/*
+	 * Check the shared pg_statistic catalog first.  A GTT never has rows
+	 * there (ANALYZE diverts its stats to the per-session hash), so a
+	 * syscache hit settles the lookup without touching the GTT hash: once any
+	 * GTT has been ANALYZEd in this session, probing the hash first would
+	 * cost every planner stats lookup for ordinary analyzed tables a
+	 * guaranteed-miss hash search on this hot path.
+	 */
+	tuple = SearchSysCache3(STATRELATTINH,
+							ObjectIdGetDatum(relid),
+							Int16GetDatum(attnum),
+							BoolGetDatum(inh));
+	if (HeapTupleIsValid(tuple))
+	{
+		*freefunc = ReleaseSysCache;
+		return tuple;
+	}
+
+	/* No shared stats: per-session GTT stats, or no stats at all. */
+	if (include_gtt)
+	{
+		tuple = GttSearchColumnStats(relid, attnum, inh);
+		if (HeapTupleIsValid(tuple))
+		{
+			*freefunc = GttReleaseColumnStats;
+			return tuple;
+		}
+	}
+
+	*freefunc = ReleaseSysCache;
+	return NULL;
+}
+
+/*
+ * gtt_reset_colstats_for_rel
+ *		Remove all per-session column statistics for a given relation.
+ *
+ * Used when stats are invalidated (TRUNCATE, ON COMMIT DELETE ROWS, DROP).
+ */
+static void
+gtt_reset_colstats_for_rel(Oid relid)
+{
+	HASH_SEQ_STATUS status;
+	GttColStatsEntry *entry;
+	List	   *keys_to_remove = NIL;
+	ListCell   *lc;
+
+	if (gtt_colstats_hash == NULL)
+		return;
+
+	/*
+	 * Collect matching keys first; we can't remove hash entries during an
+	 * active hash_seq_search scan.
+	 */
+	hash_seq_init(&status, gtt_colstats_hash);
+	while ((entry = (GttColStatsEntry *) hash_seq_search(&status)) != NULL)
+	{
+		GttColStatsKey *keycopy;
+
+		if (entry->key.relid != relid)
+			continue;
+
+		keycopy = (GttColStatsKey *) palloc(sizeof(*keycopy));
+		*keycopy = entry->key;
+		keys_to_remove = lappend(keys_to_remove, keycopy);
+	}
+
+	foreach(lc, keys_to_remove)
+	{
+		GttColStatsKey *key = (GttColStatsKey *) lfirst(lc);
+
+		entry = (GttColStatsEntry *) hash_search(gtt_colstats_hash, key,
+												 HASH_REMOVE, NULL);
+		if (entry != NULL && entry->statsTuple != NULL)
+			heap_freetuple(entry->statsTuple);
+		pfree(key);
+	}
+	list_free(keys_to_remove);
+}
+
 /*
  * PreCommit_gtt_on_commit
  *		Truncate ON COMMIT DELETE ROWS GTTs at commit.
@@ -1218,7 +1648,7 @@ PreCommit_gtt_on_commit(void)
 	if (!(MyXactFlags & XACT_FLAGS_ACCESSEDTEMPNAMESPACE))
 		return;
 
-	/* First pass: identify ON COMMIT DELETE ROWS heaps to truncate. */
+	/* First pass: reset heap entries and remember which heaps were wiped. */
 	hash_seq_init(&status, gtt_storage_hash);
 	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
 	{
@@ -1239,6 +1669,7 @@ PreCommit_gtt_on_commit(void)
 						MAIN_FORKNUM) == 0)
 			continue;
 
+		entry->stats_valid = false;
 		heap_relids = lappend_oid(heap_relids, entry->relid);
 
 		/*
@@ -1282,6 +1713,10 @@ PreCommit_gtt_on_commit(void)
 			gtt_truncate_smgr(entry);
 	}
 
+	/* Column stats live in a second hash; clear them for each truncated rel. */
+	foreach_oid(relid, heap_relids)
+		gtt_reset_colstats_for_rel(relid);
+
 	list_free(heap_relids);
 }
 
@@ -1372,6 +1807,13 @@ gtt_session_cleanup(int code, Datum arg)
 	HASH_SEQ_STATUS status;
 	GttStorageEntry *entry;
 
+	/*
+	 * The column-stats hash lives in TopMemoryContext and will be torn down
+	 * with the rest of process memory shortly after we return; nothing to do
+	 * here.  We only walk gtt_storage_hash because each entry owns
+	 * externally-visible resources (on-disk files and a session lock) that
+	 * must be released explicitly.
+	 */
 	if (gtt_storage_hash == NULL)
 		return;
 
@@ -1387,3 +1829,328 @@ gtt_session_cleanup(int code, Datum arg)
 		}
 	}
 }
+
+/*
+ * format_stats_values_as_text
+ *		Convert the values from an AttStatsSlot into a text representation.
+ *
+ * We build a PostgreSQL array of the slot's element type and return its
+ * array_out textual form.  That gives proper array-literal escaping for
+ * values containing commas, braces, double quotes, backslashes, etc.,
+ * matching what pg_stats produces via its anyarray columns.
+ */
+static char *
+format_stats_values_as_text(AttStatsSlot *sslot)
+{
+	ArrayType  *arr;
+	int16		typlen;
+	bool		typbyval;
+	char		typalign;
+
+	get_typlenbyvalalign(sslot->valuetype, &typlen, &typbyval, &typalign);
+	arr = construct_array(sslot->values, sslot->nvalues,
+						  sslot->valuetype, typlen, typbyval, typalign);
+
+	return OidOutputFunctionCall(F_ARRAY_OUT, PointerGetDatum(arr));
+}
+
+/*
+ * pg_gtt_relstats
+ *		Return per-session relation-level statistics for global temporary tables.
+ *
+ * If a regclass argument is provided, returns stats only for that table.
+ * If NULL (the default), returns stats for all GTTs with valid session stats.
+ */
+Datum
+pg_gtt_relstats(PG_FUNCTION_ARGS)
+{
+#define PG_GTT_SESSION_RELSTATS_COLS 5
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	Oid			filter_relid = InvalidOid;
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+	Datum		values[PG_GTT_SESSION_RELSTATS_COLS];
+	bool		nulls[PG_GTT_SESSION_RELSTATS_COLS];
+
+	if (!PG_ARGISNULL(0))
+		filter_relid = PG_GETARG_OID(0);
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (gtt_storage_hash == NULL)
+		return (Datum) 0;
+
+	memset(nulls, 0, sizeof(nulls));
+
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		char	   *relname;
+
+		if (!entry->stats_valid)
+			continue;
+		if (OidIsValid(filter_relid) && entry->relid != filter_relid)
+			continue;
+
+		/*
+		 * Respect SELECT privilege on the target relation so callers can't
+		 * inspect stats for relations they can't see.  Mirrors pg_stats.
+		 */
+		if (pg_class_aclcheck(entry->relid, GetUserId(), ACL_SELECT) != ACLCHECK_OK)
+			continue;
+
+		relname = get_rel_name(entry->relid);
+		if (relname == NULL)
+			continue;
+
+		values[0] = ObjectIdGetDatum(entry->relid);
+		values[1] = CStringGetTextDatum(relname);
+		values[2] = Int32GetDatum((int32) entry->relpages);
+		values[3] = Float4GetDatum(entry->reltuples);
+		values[4] = Int32GetDatum((int32) entry->relallvisible);
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * pg_gtt_colstats
+ *		Return per-session column-level statistics for global temporary tables.
+ *
+ * Returns stats in a format similar to the pg_stats view: scalar stats
+ * (null_frac, avg_width, n_distinct), MCVs, histograms, and correlation.
+ * Array-typed values are converted to text representation.
+ *
+ * If a regclass argument is provided, returns stats only for that table.
+ * If NULL (the default), returns stats for all GTTs with column stats.
+ */
+Datum
+pg_gtt_colstats(PG_FUNCTION_ARGS)
+{
+#define PG_GTT_SESSION_COLSTATS_COLS 12
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	Oid			filter_relid = InvalidOid;
+	HASH_SEQ_STATUS status;
+	GttColStatsEntry *csentry;
+
+	if (!PG_ARGISNULL(0))
+		filter_relid = PG_GETARG_OID(0);
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	if (gtt_colstats_hash == NULL)
+		return (Datum) 0;
+
+	hash_seq_init(&status, gtt_colstats_hash);
+	while ((csentry = (GttColStatsEntry *) hash_seq_search(&status)) != NULL)
+	{
+		HeapTuple	statstuple = csentry->statsTuple;
+		Form_pg_statistic stats;
+		char	   *relname;
+		char	   *attname;
+		int			mcv_slot;
+		int			hist_slot;
+		int			corr_slot;
+		int			k;
+		Datum		values[PG_GTT_SESSION_COLSTATS_COLS];
+		bool		nulls[PG_GTT_SESSION_COLSTATS_COLS];
+
+		if (statstuple == NULL)
+			continue;
+		if (OidIsValid(filter_relid) && csentry->key.relid != filter_relid)
+			continue;
+
+		/*
+		 * Require column-level SELECT privilege (or table-level) on the
+		 * attribute to see its stats, matching pg_stats behavior.
+		 */
+		if (pg_class_aclcheck(csentry->key.relid, GetUserId(),
+							  ACL_SELECT) != ACLCHECK_OK &&
+			pg_attribute_aclcheck(csentry->key.relid, csentry->key.attnum,
+								  GetUserId(), ACL_SELECT) != ACLCHECK_OK)
+			continue;
+
+		relname = get_rel_name(csentry->key.relid);
+		if (relname == NULL)
+			continue;
+
+		stats = (Form_pg_statistic) GETSTRUCT(statstuple);
+
+		/* Start with all nulls, then fill in non-null columns */
+		memset(nulls, true, sizeof(nulls));
+
+		values[0] = ObjectIdGetDatum(csentry->key.relid);
+		nulls[0] = false;
+		values[1] = CStringGetTextDatum(relname);
+		nulls[1] = false;
+		values[2] = Int16GetDatum(csentry->key.attnum);
+		nulls[2] = false;
+
+		attname = get_attname(csentry->key.relid, csentry->key.attnum, true);
+		if (attname != NULL)
+		{
+			values[3] = CStringGetTextDatum(attname);
+			nulls[3] = false;
+		}
+
+		values[4] = BoolGetDatum(csentry->key.inh);
+		nulls[4] = false;
+		values[5] = Float4GetDatum(stats->stanullfrac);
+		nulls[5] = false;
+		values[6] = Int32GetDatum(stats->stawidth);
+		nulls[6] = false;
+		values[7] = Float4GetDatum(stats->stadistinct);
+		nulls[7] = false;
+
+		/* Find which slots contain MCV, histogram, and correlation */
+		mcv_slot = -1;
+		hist_slot = -1;
+		corr_slot = -1;
+		for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
+		{
+			int16		kind = (&stats->stakind1)[k];
+
+			if (kind == STATISTIC_KIND_MCV)
+				mcv_slot = k;
+			else if (kind == STATISTIC_KIND_HISTOGRAM)
+				hist_slot = k;
+			else if (kind == STATISTIC_KIND_CORRELATION)
+				corr_slot = k;
+		}
+
+		/* MCV values (as text) and frequencies (as float4[]) */
+		if (mcv_slot >= 0)
+		{
+			AttStatsSlot sslot;
+
+			if (get_attstatsslot(&sslot, statstuple, STATISTIC_KIND_MCV,
+								 InvalidOid,
+								 ATTSTATSSLOT_VALUES | ATTSTATSSLOT_NUMBERS))
+			{
+				values[8] = CStringGetTextDatum(
+												format_stats_values_as_text(&sslot));
+				nulls[8] = false;
+
+				if (sslot.nnumbers > 0)
+				{
+					Datum	   *num_datums;
+					int			j;
+
+					num_datums = (Datum *) palloc(sslot.nnumbers * sizeof(Datum));
+					for (j = 0; j < sslot.nnumbers; j++)
+						num_datums[j] = Float4GetDatum(sslot.numbers[j]);
+					values[9] = PointerGetDatum(
+												construct_array_builtin(num_datums, sslot.nnumbers,
+																		FLOAT4OID));
+					nulls[9] = false;
+					pfree(num_datums);
+				}
+
+				free_attstatsslot(&sslot);
+			}
+		}
+
+		/* Histogram bounds (as text) */
+		if (hist_slot >= 0)
+		{
+			AttStatsSlot sslot;
+
+			if (get_attstatsslot(&sslot, statstuple, STATISTIC_KIND_HISTOGRAM,
+								 InvalidOid, ATTSTATSSLOT_VALUES))
+			{
+				values[10] = CStringGetTextDatum(
+												 format_stats_values_as_text(&sslot));
+				nulls[10] = false;
+
+				free_attstatsslot(&sslot);
+			}
+		}
+
+		/* Correlation */
+		if (corr_slot >= 0)
+		{
+			AttStatsSlot sslot;
+
+			if (get_attstatsslot(&sslot, statstuple, STATISTIC_KIND_CORRELATION,
+								 InvalidOid, ATTSTATSSLOT_NUMBERS))
+			{
+				if (sslot.nnumbers > 0)
+				{
+					values[11] = Float4GetDatum(sslot.numbers[0]);
+					nulls[11] = false;
+				}
+
+				free_attstatsslot(&sslot);
+			}
+		}
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+							 values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+/*
+ * pg_gtt_clear_stats
+ *		Discard per-session relation- and column-level statistics for a GTT.
+ *
+ * If a regclass argument is provided, clears stats only for that table.  If
+ * NULL (the default), clears stats for every GTT this session has touched.
+ * Affects only the calling session's private state; the planner falls back
+ * to default estimates until ANALYZE runs again in this session.
+ *
+ * Privilege rule mirrors the read-side SRFs (pg_gtt_relstats /
+ * pg_gtt_colstats): SELECT on the relation is sufficient.  A user can only
+ * affect stats they could already see, and the cleared state is private to
+ * the calling backend, so a stricter check would not buy anything.
+ */
+Datum
+pg_gtt_clear_stats(PG_FUNCTION_ARGS)
+{
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+	List	   *to_reset = NIL;
+	Oid			filter_relid = InvalidOid;
+
+	if (!PG_ARGISNULL(0))
+	{
+		filter_relid = PG_GETARG_OID(0);
+		if (OidIsValid(filter_relid))
+		{
+			if (pg_class_aclcheck(filter_relid, GetUserId(),
+								  ACL_SELECT) == ACLCHECK_OK)
+				GttResetSessionStats(filter_relid);
+		}
+		PG_RETURN_VOID();
+	}
+
+	if (gtt_storage_hash == NULL)
+		PG_RETURN_VOID();
+
+	/*
+	 * Collect the relids first.  GttResetSessionStats() calls
+	 * gtt_reset_colstats_for_rel() which walks the column-stats hash, and
+	 * mutating either hash inside an open hash_seq_search of the storage hash
+	 * is fragile; deferring keeps the iteration simple.
+	 */
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->is_index)
+			continue;
+		if (pg_class_aclcheck(entry->relid, GetUserId(),
+							  ACL_SELECT) != ACLCHECK_OK)
+			continue;
+		to_reset = lappend_oid(to_reset, entry->relid);
+	}
+
+	foreach_oid(relid, to_reset)
+		GttResetSessionStats(relid);
+	list_free(to_reset);
+
+	PG_RETURN_VOID();
+}
diff --git a/src/backend/catalog/system_functions.sql b/src/backend/catalog/system_functions.sql
index c3c0a6e84ed..14e7bba3ba1 100644
--- a/src/backend/catalog/system_functions.sql
+++ b/src/backend/catalog/system_functions.sql
@@ -366,3 +366,41 @@ CREATE OR REPLACE FUNCTION ts_debug(document text,
 BEGIN ATOMIC
     SELECT * FROM ts_debug(get_current_ts_config(), $1);
 END;
+
+CREATE OR REPLACE FUNCTION pg_gtt_relstats(
+    relid regclass DEFAULT NULL,
+    OUT table_oid oid,
+    OUT table_name text,
+    OUT relpages int4,
+    OUT reltuples float4,
+    OUT relallvisible int4)
+ RETURNS SETOF record
+ LANGUAGE internal
+ VOLATILE CALLED ON NULL INPUT ROWS 10
+AS 'pg_gtt_relstats';
+
+CREATE OR REPLACE FUNCTION pg_gtt_colstats(
+    relid regclass DEFAULT NULL,
+    OUT table_oid oid,
+    OUT table_name text,
+    OUT attnum int2,
+    OUT attname text,
+    OUT inherited bool,
+    OUT null_frac float4,
+    OUT avg_width int4,
+    OUT n_distinct float4,
+    OUT most_common_vals text,
+    OUT most_common_freqs float4[],
+    OUT histogram_bounds text,
+    OUT correlation float4)
+ RETURNS SETOF record
+ LANGUAGE internal
+ VOLATILE CALLED ON NULL INPUT ROWS 10
+AS 'pg_gtt_colstats';
+
+CREATE OR REPLACE FUNCTION pg_gtt_clear_stats(
+    relid regclass DEFAULT NULL)
+ RETURNS void
+ LANGUAGE internal
+ VOLATILE CALLED ON NULL INPUT
+AS 'pg_gtt_clear_stats';
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index f66e80b757c..397d3ad3850 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -28,7 +28,9 @@
 #include "access/xact.h"
 #include "catalog/index.h"
 #include "catalog/indexing.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_inherits.h"
+#include "catalog/storage_gtt.h"
 #include "commands/progress.h"
 #include "commands/tablecmds.h"
 #include "commands/vacuum.h"
@@ -94,7 +96,10 @@ static int	acquire_inherited_sample_rows(Relation onerel, int elevel,
 										  HeapTuple *rows, int targrows,
 										  double *totalrows, double *totaldeadrows);
 static void update_attstats(Oid relid, bool inh,
-							int natts, VacAttrStats **vacattrstats);
+							int natts, VacAttrStats **vacattrstats,
+							bool is_gtt);
+static HeapTuple build_statstuple(Oid relid, bool inh,
+								  VacAttrStats *stats, TupleDesc tupdesc);
 static Datum std_fetch_func(VacAttrStatsP stats, int rownum, bool *isNull);
 static Datum ind_fetch_func(VacAttrStatsP stats, int rownum, bool *isNull);
 
@@ -316,7 +321,8 @@ do_analyze_rel(Relation onerel, const VacuumParams *params,
 	int			nindexes;
 	bool		verbose,
 				instrument,
-				hasindex;
+				hasindex,
+				is_gtt;
 	VacAttrStats **vacattrstats;
 	AnlIndexData *indexdata;
 	int			targrows,
@@ -340,6 +346,7 @@ do_analyze_rel(Relation onerel, const VacuumParams *params,
 	verbose = (params->options & VACOPT_VERBOSE) != 0;
 	instrument = (verbose || (AmAutoVacuumWorkerProcess() &&
 							  params->log_analyze_min_duration >= 0));
+	is_gtt = (RelationIsGlobalTemp(onerel));
 	if (inh)
 		ereport(elevel,
 				(errmsg("analyzing \"%s.%s\" inheritance tree",
@@ -608,21 +615,27 @@ do_analyze_rel(Relation onerel, const VacuumParams *params,
 		 * Emit the completed stats rows into pg_statistic, replacing any
 		 * previous statistics for the target columns.  (If there are stats in
 		 * pg_statistic for columns we didn't process, we leave them alone.)
+		 *
+		 * For global temporary tables, update_attstats stores the per-column
+		 * stats in backend-local memory instead of writing pg_statistic, so
+		 * each session sees its own sample.  Extended statistics are not
+		 * supported for GTTs and are skipped.
 		 */
 		update_attstats(RelationGetRelid(onerel), inh,
-						attr_cnt, vacattrstats);
+						attr_cnt, vacattrstats, is_gtt);
 
 		for (ind = 0; ind < nindexes; ind++)
 		{
 			AnlIndexData *thisdata = &indexdata[ind];
 
 			update_attstats(RelationGetRelid(Irel[ind]), false,
-							thisdata->attr_cnt, thisdata->vacattrstats);
+							thisdata->attr_cnt, thisdata->vacattrstats,
+							is_gtt);
 		}
 
-		/* Build extended statistics (if there are any). */
-		BuildRelationExtStatistics(onerel, inh, totalrows, numrows, rows,
-								   attr_cnt, vacattrstats);
+		if (!is_gtt)
+			BuildRelationExtStatistics(onerel, inh, totalrows, numrows, rows,
+									   attr_cnt, vacattrstats);
 	}
 
 	pgstat_progress_update_param(PROGRESS_ANALYZE_PHASE,
@@ -650,36 +663,52 @@ do_analyze_rel(Relation onerel, const VacuumParams *params,
 		/*
 		 * Update pg_class for table relation.  CCI first, in case acquirefunc
 		 * updated pg_class.
+		 *
+		 * For global temporary tables, store page/tuple statistics in the
+		 * per-session hash instead of pg_class: the shared catalog values
+		 * would be meaningless since each session has its own private data.
+		 * The planner reads them back via GttGetSessionStats().
 		 */
-		CommandCounterIncrement();
-		vac_update_relstats(onerel,
-							relpages,
-							totalrows,
-							relallvisible,
-							relallfrozen,
-							hasindex,
-							InvalidTransactionId,
-							InvalidMultiXactId,
-							NULL, NULL,
-							in_outer_xact);
-
-		/* Same for indexes */
-		for (ind = 0; ind < nindexes; ind++)
+		if (is_gtt)
+			GttUpdateSessionStats(RelationGetRelid(onerel),
+								  relpages, totalrows, relallvisible);
+		else
 		{
-			AnlIndexData *thisdata = &indexdata[ind];
-			double		totalindexrows;
-
-			totalindexrows = ceil(thisdata->tupleFract * totalrows);
-			vac_update_relstats(Irel[ind],
-								RelationGetNumberOfBlocks(Irel[ind]),
-								totalindexrows,
-								0, 0,
-								false,
+			CommandCounterIncrement();
+			vac_update_relstats(onerel,
+								relpages,
+								totalrows,
+								relallvisible,
+								relallfrozen,
+								hasindex,
 								InvalidTransactionId,
 								InvalidMultiXactId,
 								NULL, NULL,
 								in_outer_xact);
 		}
+
+		/* Same for indexes */
+		for (ind = 0; ind < nindexes; ind++)
+		{
+			AnlIndexData *thisdata = &indexdata[ind];
+			double		totalindexrows;
+
+			totalindexrows = ceil(thisdata->tupleFract * totalrows);
+			if (is_gtt)
+				GttUpdateSessionStats(RelationGetRelid(Irel[ind]),
+									  RelationGetNumberOfBlocks(Irel[ind]),
+									  totalindexrows, 0);
+			else
+				vac_update_relstats(Irel[ind],
+									RelationGetNumberOfBlocks(Irel[ind]),
+									totalindexrows,
+									0, 0,
+									false,
+									InvalidTransactionId,
+									InvalidMultiXactId,
+									NULL, NULL,
+									in_outer_xact);
+		}
 	}
 	else if (onerel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
 	{
@@ -1688,6 +1717,93 @@ acquire_inherited_sample_rows(Relation onerel, int elevel,
 }
 
 
+/*
+ * build_statstuple
+ *		Build a pg_statistic-format HeapTuple from a VacAttrStats.
+ *
+ * The tuple, and sizable intermediate arrays, are allocated in the current
+ * memory context.  A caller that needs the tuple to live beyond the current
+ * transaction should copy it (heap_copytuple) into the longer-lived context
+ * rather than build there, so the intermediates don't leak into it.
+ */
+static HeapTuple
+build_statstuple(Oid relid, bool inh, VacAttrStats *stats, TupleDesc tupdesc)
+{
+	int			i,
+				k,
+				n;
+	Datum		values[Natts_pg_statistic];
+	bool		nulls[Natts_pg_statistic];
+
+	for (i = 0; i < Natts_pg_statistic; ++i)
+		nulls[i] = false;
+
+	values[Anum_pg_statistic_starelid - 1] = ObjectIdGetDatum(relid);
+	values[Anum_pg_statistic_staattnum - 1] = Int16GetDatum(stats->tupattnum);
+	values[Anum_pg_statistic_stainherit - 1] = BoolGetDatum(inh);
+	values[Anum_pg_statistic_stanullfrac - 1] = Float4GetDatum(stats->stanullfrac);
+	values[Anum_pg_statistic_stawidth - 1] = Int32GetDatum(stats->stawidth);
+	values[Anum_pg_statistic_stadistinct - 1] = Float4GetDatum(stats->stadistinct);
+	i = Anum_pg_statistic_stakind1 - 1;
+	for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
+	{
+		values[i++] = Int16GetDatum(stats->stakind[k]); /* stakindN */
+	}
+	i = Anum_pg_statistic_staop1 - 1;
+	for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
+	{
+		values[i++] = ObjectIdGetDatum(stats->staop[k]);	/* staopN */
+	}
+	i = Anum_pg_statistic_stacoll1 - 1;
+	for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
+	{
+		values[i++] = ObjectIdGetDatum(stats->stacoll[k]);	/* stacollN */
+	}
+	i = Anum_pg_statistic_stanumbers1 - 1;
+	for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
+	{
+		if (stats->stanumbers[k] != NULL)
+		{
+			int			nnum = stats->numnumbers[k];
+			Datum	   *numdatums = (Datum *) palloc(nnum * sizeof(Datum));
+			ArrayType  *arry;
+
+			for (n = 0; n < nnum; n++)
+				numdatums[n] = Float4GetDatum(stats->stanumbers[k][n]);
+			arry = construct_array_builtin(numdatums, nnum, FLOAT4OID);
+			values[i++] = PointerGetDatum(arry);	/* stanumbersN */
+		}
+		else
+		{
+			nulls[i] = true;
+			values[i++] = (Datum) 0;
+		}
+	}
+	i = Anum_pg_statistic_stavalues1 - 1;
+	for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
+	{
+		if (stats->stavalues[k] != NULL)
+		{
+			ArrayType  *arry;
+
+			arry = construct_array(stats->stavalues[k],
+								   stats->numvalues[k],
+								   stats->statypid[k],
+								   stats->statyplen[k],
+								   stats->statypbyval[k],
+								   stats->statypalign[k]);
+			values[i++] = PointerGetDatum(arry);	/* stavaluesN */
+		}
+		else
+		{
+			nulls[i] = true;
+			values[i++] = (Datum) 0;
+		}
+	}
+
+	return heap_form_tuple(tupdesc, values, nulls);
+}
+
 /*
  *	update_attstats() -- update attribute statistics for one relation
  *
@@ -1709,106 +1825,62 @@ acquire_inherited_sample_rows(Relation onerel, int elevel,
  *		Note: there would be a race condition here if two backends could
  *		ANALYZE the same table concurrently.  Presently, we lock that out
  *		by taking a self-exclusive lock on the relation in analyze_rel().
+ *
+ *		For global temporary tables (is_gtt == true) the caller wants
+ *		session-private statistics, so we build the tuples in
+ *		TopMemoryContext and store them in the backend-local GTT
+ *		column-stats hash instead of pg_statistic.  pg_statistic is opened
+ *		either way: to get a RowExclusiveLock for writing, or an
+ *		AccessShareLock for its TupleDesc.
  */
 static void
-update_attstats(Oid relid, bool inh, int natts, VacAttrStats **vacattrstats)
+update_attstats(Oid relid, bool inh, int natts, VacAttrStats **vacattrstats,
+				bool is_gtt)
 {
 	Relation	sd;
+	LOCKMODE	lockmode = is_gtt ? AccessShareLock : RowExclusiveLock;
 	int			attno;
 	CatalogIndexState indstate = NULL;
+	MemoryContext oldcxt;
 
 	if (natts <= 0)
 		return;					/* nothing to do */
 
-	sd = table_open(StatisticRelationId, RowExclusiveLock);
+	sd = table_open(StatisticRelationId, lockmode);
 
 	for (attno = 0; attno < natts; attno++)
 	{
 		VacAttrStats *stats = vacattrstats[attno];
 		HeapTuple	stup,
 					oldtup;
-		int			i,
-					k,
-					n;
-		Datum		values[Natts_pg_statistic];
-		bool		nulls[Natts_pg_statistic];
-		bool		replaces[Natts_pg_statistic];
 
 		/* Ignore attr if we weren't able to collect stats */
 		if (!stats->stats_valid)
 			continue;
 
-		/*
-		 * Construct a new pg_statistic tuple
-		 */
-		for (i = 0; i < Natts_pg_statistic; ++i)
+		if (is_gtt)
 		{
-			nulls[i] = false;
-			replaces[i] = true;
-		}
+			HeapTuple	stup_copy;
 
-		values[Anum_pg_statistic_starelid - 1] = ObjectIdGetDatum(relid);
-		values[Anum_pg_statistic_staattnum - 1] = Int16GetDatum(stats->tupattnum);
-		values[Anum_pg_statistic_stainherit - 1] = BoolGetDatum(inh);
-		values[Anum_pg_statistic_stanullfrac - 1] = Float4GetDatum(stats->stanullfrac);
-		values[Anum_pg_statistic_stawidth - 1] = Int32GetDatum(stats->stawidth);
-		values[Anum_pg_statistic_stadistinct - 1] = Float4GetDatum(stats->stadistinct);
-		i = Anum_pg_statistic_stakind1 - 1;
-		for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
-		{
-			values[i++] = Int16GetDatum(stats->stakind[k]); /* stakindN */
-		}
-		i = Anum_pg_statistic_staop1 - 1;
-		for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
-		{
-			values[i++] = ObjectIdGetDatum(stats->staop[k]);	/* staopN */
-		}
-		i = Anum_pg_statistic_stacoll1 - 1;
-		for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
-		{
-			values[i++] = ObjectIdGetDatum(stats->stacoll[k]);	/* stacollN */
-		}
-		i = Anum_pg_statistic_stanumbers1 - 1;
-		for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
-		{
-			if (stats->stanumbers[k] != NULL)
-			{
-				int			nnum = stats->numnumbers[k];
-				Datum	   *numdatums = (Datum *) palloc(nnum * sizeof(Datum));
-				ArrayType  *arry;
+			/*
+			 * GTT stats tuples outlive the current memory context: they are
+			 * owned by the backend-local hash in TopMemoryContext.  Build the
+			 * tuple in the current (transaction-lifetime) context, so the
+			 * sizable intermediate arrays build_statstuple creates are
+			 * reclaimed with it, and copy only the finished tuple into
+			 * TopMemoryContext.
+			 */
+			stup = build_statstuple(relid, inh, stats, RelationGetDescr(sd));
+			oldcxt = MemoryContextSwitchTo(TopMemoryContext);
+			stup_copy = heap_copytuple(stup);
+			MemoryContextSwitchTo(oldcxt);
+			heap_freetuple(stup);
 
-				for (n = 0; n < nnum; n++)
-					numdatums[n] = Float4GetDatum(stats->stanumbers[k][n]);
-				arry = construct_array_builtin(numdatums, nnum, FLOAT4OID);
-				values[i++] = PointerGetDatum(arry);	/* stanumbersN */
-			}
-			else
-			{
-				nulls[i] = true;
-				values[i++] = (Datum) 0;
-			}
+			GttStoreSessionColumnStats(relid, stats->tupattnum, inh, stup_copy);
+			continue;
 		}
-		i = Anum_pg_statistic_stavalues1 - 1;
-		for (k = 0; k < STATISTIC_NUM_SLOTS; k++)
-		{
-			if (stats->stavalues[k] != NULL)
-			{
-				ArrayType  *arry;
 
-				arry = construct_array(stats->stavalues[k],
-									   stats->numvalues[k],
-									   stats->statypid[k],
-									   stats->statyplen[k],
-									   stats->statypbyval[k],
-									   stats->statypalign[k]);
-				values[i++] = PointerGetDatum(arry);	/* stavaluesN */
-			}
-			else
-			{
-				nulls[i] = true;
-				values[i++] = (Datum) 0;
-			}
-		}
+		stup = build_statstuple(relid, inh, stats, RelationGetDescr(sd));
 
 		/* Is there already a pg_statistic tuple for this attribute? */
 		oldtup = SearchSysCache3(STATRELATTINH,
@@ -1822,19 +1894,23 @@ update_attstats(Oid relid, bool inh, int natts, VacAttrStats **vacattrstats)
 
 		if (HeapTupleIsValid(oldtup))
 		{
-			/* Yes, replace it */
-			stup = heap_modify_tuple(oldtup,
-									 RelationGetDescr(sd),
-									 values,
-									 nulls,
-									 replaces);
-			ReleaseSysCache(oldtup);
+			/*
+			 * Yes, replace it.  Preserve the old tuple's header fields
+			 * (t_self, t_ctid, t_tableOid) as heap_modify_tuple did before
+			 * build_statstuple was extracted; heap_update doesn't strictly
+			 * require all three, but keeping them consistent with the
+			 * previous behavior avoids surprises for any caller that inspects
+			 * them.
+			 */
+			stup->t_self = oldtup->t_self;
+			stup->t_data->t_ctid = oldtup->t_data->t_ctid;
+			stup->t_tableOid = oldtup->t_tableOid;
 			CatalogTupleUpdateWithInfo(sd, &stup->t_self, stup, indstate);
+			ReleaseSysCache(oldtup);
 		}
 		else
 		{
 			/* No, insert new tuple */
-			stup = heap_form_tuple(RelationGetDescr(sd), values, nulls);
 			CatalogTupleInsertWithInfo(sd, stup, indstate);
 		}
 
@@ -1843,7 +1919,7 @@ update_attstats(Oid relid, bool inh, int natts, VacAttrStats **vacattrstats)
 
 	if (indstate != NULL)
 		CatalogCloseIndexes(indstate);
-	table_close(sd, RowExclusiveLock);
+	table_close(sd, lockmode);
 }
 
 /*
diff --git a/src/backend/executor/nodeHash.c b/src/backend/executor/nodeHash.c
index 8825bb6fa23..4124f3155c5 100644
--- a/src/backend/executor/nodeHash.c
+++ b/src/backend/executor/nodeHash.c
@@ -29,6 +29,7 @@
 #include "access/htup_details.h"
 #include "access/parallel.h"
 #include "catalog/pg_statistic.h"
+#include "catalog/storage_gtt.h"
 #include "commands/tablespace.h"
 #include "executor/executor.h"
 #include "executor/hashjoin.h"
@@ -2430,6 +2431,7 @@ ExecHashBuildSkewHash(HashState *hashstate, HashJoinTable hashtable,
 					  Hash *node, int mcvsToUse)
 {
 	HeapTupleData *statsTuple;
+	void		(*freeStatsTuple) (HeapTuple);
 	AttStatsSlot sslot;
 
 	/* Do nothing if planner didn't identify the outer relation's join key */
@@ -2442,10 +2444,11 @@ ExecHashBuildSkewHash(HashState *hashstate, HashJoinTable hashtable,
 	/*
 	 * Try to find the MCV statistics for the outer relation's join key.
 	 */
-	statsTuple = SearchSysCache3(STATRELATTINH,
-								 ObjectIdGetDatum(node->skewTable),
-								 Int16GetDatum(node->skewColumn),
-								 BoolGetDatum(node->skewInherit));
+	statsTuple = SearchStats(node->skewTable,
+							 node->skewColumn,
+							 node->skewInherit,
+							 true,
+							 &freeStatsTuple);
 	if (!HeapTupleIsValid(statsTuple))
 		return;
 
@@ -2471,7 +2474,7 @@ ExecHashBuildSkewHash(HashState *hashstate, HashJoinTable hashtable,
 		if (frac < SKEW_MIN_OUTER_FRACTION)
 		{
 			free_attstatsslot(&sslot);
-			ReleaseSysCache(statsTuple);
+			freeStatsTuple(statsTuple);
 			return;
 		}
 
@@ -2567,7 +2570,7 @@ ExecHashBuildSkewHash(HashState *hashstate, HashJoinTable hashtable,
 		free_attstatsslot(&sslot);
 	}
 
-	ReleaseSysCache(statsTuple);
+	freeStatsTuple(statsTuple);
 }
 
 /*
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index 7c4be174869..6f88ac6c6d2 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -28,7 +28,9 @@
 #include "catalog/catalog.h"
 #include "catalog/heap.h"
 #include "catalog/pg_am.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_proc.h"
+#include "catalog/storage_gtt.h"
 #include "catalog/pg_statistic_ext.h"
 #include "catalog/pg_statistic_ext_data.h"
 #include "foreign/fdwapi.h"
@@ -1311,6 +1313,64 @@ estimate_rel_size(Relation rel, int32 *attr_widths,
 	BlockNumber relallvisible;
 	double		density;
 
+	/*
+	 * For global temporary tables, use per-session statistics if available.
+	 * Each session has its own private GTT data, so the shared pg_class
+	 * statistics are not meaningful.  Per-session stats are populated by
+	 * ANALYZE and stored in the backend-local GTT storage hash.
+	 *
+	 * For both heap tables and indexes, we use the actual per-session page
+	 * count and the per-session tuple density from ANALYZE.  If ANALYZE
+	 * hasn't been run yet, we skip the "never vacuumed" minimum-10-pages
+	 * heuristic (which would overestimate, since GTTs genuinely start empty)
+	 * and fall through to the normal estimation paths.
+	 */
+	if (RelationIsGlobalTemp(rel))
+	{
+		BlockNumber sess_pages;
+		double		sess_tuples;
+		BlockNumber sess_allvisible;
+
+		if (GttGetSessionStats(RelationGetRelid(rel),
+							   &sess_pages, &sess_tuples, &sess_allvisible))
+		{
+			curpages = RelationGetNumberOfBlocks(rel);
+			*pages = curpages;
+
+			if (curpages == 0)
+			{
+				*tuples = 0;
+				*allvisfrac = 0;
+				return;
+			}
+
+			/* Use per-session tuple density to estimate current tuples */
+			if (sess_tuples >= 0 && sess_pages > 0)
+				density = sess_tuples / (double) sess_pages;
+			else
+			{
+				int32		tuple_width;
+
+				tuple_width = get_rel_data_width(rel, attr_widths);
+				tuple_width += MAXALIGN(SizeofHeapTupleHeader);
+				tuple_width += sizeof(ItemIdData);
+				density = (BLCKSZ - SizeOfPageHeaderData) / tuple_width;
+			}
+			*tuples = rint(density * (double) curpages);
+
+			if (sess_allvisible == 0 || curpages <= 0)
+				*allvisfrac = 0;
+			else if ((double) sess_allvisible >= curpages)
+				*allvisfrac = 1;
+			else
+				*allvisfrac = (double) sess_allvisible / curpages;
+
+			return;
+		}
+
+		/* No per-session stats yet; fall through to normal estimation. */
+	}
+
 	if (RELKIND_HAS_TABLE_AM(rel->rd_rel->relkind))
 	{
 		table_relation_estimate_size(rel, attr_widths, pages, tuples,
diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
index d6efd07073a..ba98454a628 100644
--- a/src/backend/utils/adt/selfuncs.c
+++ b/src/backend/utils/adt/selfuncs.c
@@ -107,6 +107,7 @@
 #include "catalog/pg_operator.h"
 #include "catalog/pg_statistic.h"
 #include "catalog/pg_statistic_ext.h"
+#include "catalog/storage_gtt.h"
 #include "executor/nodeAgg.h"
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
@@ -5830,11 +5831,10 @@ examine_variable(PlannerInfo *root, Node *node, int varRelid,
 						else if (index->indpred == NIL)
 						{
 							vardata->statsTuple =
-								SearchSysCache3(STATRELATTINH,
-												ObjectIdGetDatum(index->indexoid),
-												Int16GetDatum(pos + 1),
-												BoolGetDatum(false));
-							vardata->freefunc = ReleaseSysCache;
+								SearchStats(index->indexoid,
+											pos + 1, false,
+											true,
+											&vardata->freefunc);
 
 							if (HeapTupleIsValid(vardata->statsTuple))
 							{
@@ -6060,11 +6060,11 @@ examine_simple_variable(PlannerInfo *root, Var *var,
 		 * Plain table or parent of an inheritance appendrel, so look up the
 		 * column in pg_statistic
 		 */
-		vardata->statsTuple = SearchSysCache3(STATRELATTINH,
-											  ObjectIdGetDatum(rte->relid),
-											  Int16GetDatum(var->varattno),
-											  BoolGetDatum(rte->inh));
-		vardata->freefunc = ReleaseSysCache;
+		vardata->statsTuple = SearchStats(rte->relid,
+										  var->varattno,
+										  rte->inh,
+										  true,
+										  &vardata->freefunc);
 
 		if (HeapTupleIsValid(vardata->statsTuple))
 		{
@@ -6537,11 +6537,10 @@ examine_indexcol_variable(PlannerInfo *root, IndexOptInfo *index,
 		}
 		else
 		{
-			vardata->statsTuple = SearchSysCache3(STATRELATTINH,
-												  ObjectIdGetDatum(relid),
-												  Int16GetDatum(colnum),
-												  BoolGetDatum(rte->inh));
-			vardata->freefunc = ReleaseSysCache;
+			vardata->statsTuple = SearchStats(relid, colnum,
+											  rte->inh,
+											  true,
+											  &vardata->freefunc);
 		}
 	}
 	else
@@ -6563,11 +6562,9 @@ examine_indexcol_variable(PlannerInfo *root, IndexOptInfo *index,
 		}
 		else
 		{
-			vardata->statsTuple = SearchSysCache3(STATRELATTINH,
-												  ObjectIdGetDatum(relid),
-												  Int16GetDatum(colnum),
-												  BoolGetDatum(false));
-			vardata->freefunc = ReleaseSysCache;
+			vardata->statsTuple = SearchStats(relid, colnum, false,
+											  true,
+											  &vardata->freefunc);
 		}
 	}
 }
@@ -9117,11 +9114,9 @@ brincostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
 			else
 			{
 				vardata.statsTuple =
-					SearchSysCache3(STATRELATTINH,
-									ObjectIdGetDatum(rte->relid),
-									Int16GetDatum(attnum),
-									BoolGetDatum(false));
-				vardata.freefunc = ReleaseSysCache;
+					SearchStats(rte->relid, attnum, false,
+								true,
+								&vardata.freefunc);
 			}
 		}
 		else
@@ -9147,11 +9142,10 @@ brincostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
 			}
 			else
 			{
-				vardata.statsTuple = SearchSysCache3(STATRELATTINH,
-													 ObjectIdGetDatum(index->indexoid),
-													 Int16GetDatum(attnum),
-													 BoolGetDatum(false));
-				vardata.freefunc = ReleaseSysCache;
+				vardata.statsTuple =
+					SearchStats(index->indexoid, attnum, false,
+								true,
+								&vardata.freefunc);
 			}
 		}
 
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index 036de5f79ef..7a5249fed98 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -40,6 +40,7 @@
 #include "catalog/pg_range.h"
 #include "catalog/pg_statistic.h"
 #include "catalog/pg_subscription.h"
+#include "catalog/storage_gtt.h"
 #include "catalog/pg_transform.h"
 #include "catalog/pg_type.h"
 #include "miscadmin.h"
@@ -3467,6 +3468,7 @@ get_attavgwidth(Oid relid, AttrNumber attnum)
 {
 	HeapTuple	tp;
 	int32		stawidth;
+	void		(*freefunc) (HeapTuple);
 
 	if (get_attavgwidth_hook)
 	{
@@ -3474,17 +3476,17 @@ get_attavgwidth(Oid relid, AttrNumber attnum)
 		if (stawidth > 0)
 			return stawidth;
 	}
-	tp = SearchSysCache3(STATRELATTINH,
-						 ObjectIdGetDatum(relid),
-						 Int16GetDatum(attnum),
-						 BoolGetDatum(false));
+
+	tp = SearchStats(relid, attnum, false, true, &freefunc);
 	if (HeapTupleIsValid(tp))
 	{
 		stawidth = ((Form_pg_statistic) GETSTRUCT(tp))->stawidth;
-		ReleaseSysCache(tp);
+		freefunc	(tp);
+
 		if (stawidth > 0)
 			return stawidth;
 	}
+
 	return 0;
 }
 
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index be157a5fbe9..5d2f33de66f 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12693,4 +12693,32 @@
   proname => 'hashoid8extended', prorettype => 'int8',
   proargtypes => 'oid8 int8', prosrc => 'hashoid8extended' },
 
+# Global temporary table per-session statistics
+# NB: These are redefined in system_functions.sql to add a DEFAULT NULL,
+# which can't be done here (regclass isn't in the bootstrap TypInfo, so
+# InsertOneProargdefaultsValue can't construct a Const for the default).
+{ oid => '9916',
+  descr => 'per-session relation statistics for global temporary tables',
+  proname => 'pg_gtt_relstats', prorows => '10', proretset => 't',
+  provolatile => 'v', proisstrict => 'f', prorettype => 'record',
+  proargtypes => 'regclass',
+  proallargtypes => '{regclass,oid,text,int4,float4,int4}',
+  proargmodes => '{i,o,o,o,o,o}',
+  proargnames => '{relid,table_oid,table_name,relpages,reltuples,relallvisible}',
+  prosrc => 'pg_gtt_relstats' },
+{ oid => '9917',
+  descr => 'per-session column statistics for global temporary tables',
+  proname => 'pg_gtt_colstats', prorows => '10', proretset => 't',
+  provolatile => 'v', proisstrict => 'f', prorettype => 'record',
+  proargtypes => 'regclass',
+  proallargtypes => '{regclass,oid,text,int2,text,bool,float4,int4,float4,text,_float4,text,float4}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{relid,table_oid,table_name,attnum,attname,inherited,null_frac,avg_width,n_distinct,most_common_vals,most_common_freqs,histogram_bounds,correlation}',
+  prosrc => 'pg_gtt_colstats' },
+{ oid => '9918',
+  descr => 'discard per-session statistics for global temporary tables',
+  proname => 'pg_gtt_clear_stats', provolatile => 'v', proisstrict => 'f',
+  prorettype => 'void', proargtypes => 'regclass',
+  prosrc => 'pg_gtt_clear_stats' },
+
 ]
diff --git a/src/include/catalog/storage_gtt.h b/src/include/catalog/storage_gtt.h
index c2df6641600..92a4731eb31 100644
--- a/src/include/catalog/storage_gtt.h
+++ b/src/include/catalog/storage_gtt.h
@@ -28,4 +28,20 @@ extern void GttPrepareIndexAccess(Relation indexRelation);
 extern void PreCommit_gtt_on_commit(void);
 extern void GttResetAllSessionData(void);
 
+/* Per-session relation-level statistics for planner */
+extern bool GttGetSessionStats(Oid relid, BlockNumber *relpages,
+							   double *reltuples, BlockNumber *relallvisible);
+extern void GttUpdateSessionStats(Oid relid, BlockNumber relpages,
+								  double reltuples, BlockNumber relallvisible);
+extern void GttResetSessionStats(Oid relid);
+
+/* Per-session column-level statistics for planner */
+extern void GttStoreSessionColumnStats(Oid relid, AttrNumber attnum, bool inh,
+									   HeapTuple tuple);
+extern HeapTuple GttSearchColumnStats(Oid relid, AttrNumber attnum, bool inh);
+extern void GttReleaseColumnStats(HeapTuple tuple);
+extern HeapTuple SearchStats(Oid relid, AttrNumber attnum, bool inh,
+							 bool include_gtt,
+							 void (**freefunc) (HeapTuple));
+
 #endif							/* STORAGE_GTT_H */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 714a03dd3f3..d2b5c80aee5 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1187,6 +1187,8 @@ GroupingSet
 GroupingSetData
 GroupingSetKind
 GroupingSetsPath
+GttColStatsEntry
+GttColStatsKey
 GttStorageEntry
 GttSwapUndo
 GucAction
-- 
2.43.0



  [text/x-patch] 0007-Global-temporary-tables-DDL-safety-via-a-shared-sess.patch (33.2K, ../[email protected]/8-0007-Global-temporary-tables-DDL-safety-via-a-shared-sess.patch)
  download | inline diff:
From e81095db5a1d3d4ae6b0b2a5ca020a491f69c5e6 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Sun, 15 Mar 2026 10:32:30 -0400
Subject: [PATCH 07/12] Global temporary tables: DDL safety via a shared
 sessions registry

Make DROP TABLE / ALTER TABLE / CREATE INDEX on a GTT safe against
concurrent sessions that have live per-session data.

A shared-memory sessions registry -- a hash keyed on (dbOid, relid,
ProcNumber) -- records which backends currently have live per-session
storage for each GTT.  heap_drop_with_catalog, ALTER TABLE, and
CREATE INDEX consult it through GttCheckDroppable/GttCheckAlterable
under the exclusive lock they already hold, and error out if any
other backend is listed.

No session-level heavyweight lock is taken on the GTT.  A
session-lifetime AccessShareLock might seem like the natural guard,
but it would make every AccessExclusiveLock acquisition -- a peer's
TRUNCATE of its own private data, or a DROP that the registry rejects
with a clean error anyway -- block until the registered backends
disconnect.  It also could not close the gap by itself:
ProcReleaseLocks(isCommit=false) calls LockReleaseAll(allLocks=true)
on transaction abort, dropping session locks along with transaction
locks, so a queued DROP could wipe the catalog while the aborted
session still has private storage files.  Registry entries persist
past abort and are only cleared on orderly teardown (explicit DROP,
session cleanup, or the aborting (sub)transaction being the one that
created the entry); in-flight access windows are covered by the
ordinary transaction-level locks every relation access holds.

One wrinkle: a backend that disconnects removes its registry entries
from before_shmem_exit (gtt_session_cleanup), but a client can
reconnect and issue DDL before its previous backend has finished
exiting.  Erroring out immediately on such an entry would make
reconnect-then-DROP fail spuriously.  As with DROP DATABASE's
CountOtherDBBackends(), GttCheckDroppable/GttCheckAlterable retry for
up to five seconds (gtt_wait_other_sessions_gone) before reporting
the conflict: exiting backends clear within milliseconds, while a
backend genuinely retaining data keeps its entry indefinitely and is
then reported.

The shared hash is sized at MaxBackends * GTT_SESSIONS_ENTRIES_PER_BACKEND
(currently 16 per backend) and protected by a new predefined LWLock,
GttSessionsLock.  After a crash restart, CreateSharedMemoryAndSemaphores
re-runs all the init callbacks, so the hash starts empty -- matching
the fact that the crashed backends' temp files will have been removed
by RemovePgTempFiles.
---
 src/backend/catalog/heap.c                    |   9 +
 src/backend/catalog/storage_gtt.c             | 461 +++++++++++++++++-
 src/backend/commands/tablecmds.c              |  13 +
 .../utils/activity/wait_event_names.txt       |   1 +
 src/include/catalog/storage_gtt.h             |  12 +
 src/include/storage/lwlocklist.h              |   1 +
 src/include/storage/subsystemlist.h           |   1 +
 src/tools/pgindent/typedefs.list              |   2 +
 8 files changed, 478 insertions(+), 22 deletions(-)

diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 27b3f7e72be..69d3aa58855 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -1921,9 +1921,18 @@ heap_drop_with_catalog(Oid relid)
 	 * hash entry and the session-level lock.  Physical file unlinking goes
 	 * through the normal PendingRelDelete path above (rd_locator has been
 	 * redirected to the per-session locator).
+	 *
+	 * Before scheduling cleanup, consult the shared-memory sessions registry:
+	 * if any other backend has live per-session storage for this GTT, refuse
+	 * the drop.  We hold AccessExclusiveLock, so no other session can enter
+	 * GttInitSessionStorage (which would add to the registry) until we
+	 * complete or abort.
 	 */
 	if (RelationIsGlobalTemp(rel))
+	{
+		GttCheckDroppable(relid);
 		GttScheduleDropSessionStorage(relid);
+	}
 
 	/* ensure that stats are dropped if transaction commits */
 	pgstat_drop_relation(rel);
diff --git a/src/backend/catalog/storage_gtt.c b/src/backend/catalog/storage_gtt.c
index fc6feb42f6d..fa0760707b4 100644
--- a/src/backend/catalog/storage_gtt.c
+++ b/src/backend/catalog/storage_gtt.c
@@ -43,7 +43,10 @@
 #include "nodes/pg_list.h"
 #include "storage/ipc.h"
 #include "storage/bufmgr.h"
+#include "storage/lmgr.h"
+#include "storage/lwlock.h"
 #include "storage/procnumber.h"
+#include "storage/shmem.h"
 #include "storage/smgr.h"
 #include "utils/acl.h"
 #include "utils/array.h"
@@ -69,6 +72,7 @@
  *   - storage_subid: subxact that most recently called RelationCreateStorage
  *   - index_subid: subxact that built the index for this session
  *   - stats_subid: subxact that last wrote per-session statistics
+ *   - demat_subid: subxact whose DISCARD scheduled dematerialization
  * On subxact or xact abort of a given subid, the corresponding state is
  * reverted.  On subxact commit, the subid is reparented.  See
  * gtt_subxact_callback / gtt_xact_callback.
@@ -87,11 +91,15 @@ typedef struct GttStorageEntry
 	bool		build_deferred; /* index_build deferred the physical build
 								 * because the parent heap was unmaterialized */
 	bool		on_commit_delete;	/* truncate data on commit? */
-	bool		drop_pending;	/* entry scheduled for drop at xact commit */
+	SubTransactionId drop_subid;	/* subxact that scheduled this entry's
+									 * drop (DROP TABLE/INDEX); acted on at
+									 * top-level commit */
 	SubTransactionId create_subid;	/* subxact that added this entry */
 	SubTransactionId storage_subid; /* subxact that created current storage */
 	SubTransactionId index_subid;	/* subxact that built the index */
 	SubTransactionId stats_subid;	/* subxact that last wrote session stats */
+	SubTransactionId demat_subid;	/* subxact whose DISCARD scheduled
+									 * dematerialization at commit */
 
 	/* Per-session relation statistics (set by ANALYZE) */
 	bool		stats_valid;	/* has ANALYZE been run in this session? */
@@ -105,7 +113,7 @@ static HTAB *gtt_storage_hash = NULL;
 
 /*
  * True when any entry carries rollback-sensitive state (a valid
- * create_subid/storage_subid/index_subid, or drop_pending), letting the
+ * create_subid/storage_subid/index_subid, or drop_subid), letting the
  * xact/subxact callbacks skip their full-hash scans in the common case of
  * a transaction that established no such state.  Conservative: it is only
  * cleared once a top-level transaction end has settled every entry.
@@ -169,12 +177,54 @@ static HTAB *gtt_colstats_hash = NULL;
 /* Guard against recursive index builds */
 static bool gtt_building_index = false;
 
+/*
+ * Shared-memory session registry: a hash of (dbOid, relid, ProcNumber)
+ * entries recording which backends currently have live per-session storage
+ * for each GTT.  This is the sole cross-session DDL-safety mechanism:
+ * GttCheckDroppable / GttCheckAlterable, called while the DDL session holds
+ * AccessExclusiveLock, error out when any other backend appears here.  No
+ * session-level heavyweight lock is taken for a GTT -- it would make every
+ * AccessExclusiveLock acquisition block until the registered backends
+ * disconnect, instead of failing (or proceeding, for TRUNCATE) promptly.
+ *
+ * The hash is sized at postmaster startup (see GttSessionsShmemRequest) and
+ * protected by the predefined GttSessionsLock LWLock.  Entries are added on
+ * first per-session access and removed on session cleanup / explicit drop.
+ */
+
+/*
+ * Initial-size hint for the shared GTT Sessions hash: entries per backend.
+ * Only used once, in GttSessionsShmemRequest.  Dynahash grows past this
+ * limit on demand; the value is just a sizing guess to avoid early splits
+ * on common workloads.
+ */
+#define GTT_SESSIONS_ENTRIES_PER_BACKEND 16
+
+typedef struct GttSessionsKey
+{
+	Oid			dbOid;
+	Oid			relid;
+	ProcNumber	procnum;
+} GttSessionsKey;
+
+typedef struct GttSessionsEntry
+{
+	GttSessionsKey key;			/* also the hash key -- must come first */
+	Oid			table_relid;	/* owning table: self for heaps, the parent
+								 * heap for indexes.  Lets DDL checks on a
+								 * table see sessions whose only materialized
+								 * storage is one of its indexes. */
+} GttSessionsEntry;
+
+static HTAB *GttSessionsHash = NULL;
+
 /* Local function prototypes */
 static void gtt_session_cleanup(int code, Datum arg);
 static void ensure_gtt_hash(void);
 static void ensure_gtt_colstats_hash(void);
 static void init_colstats_key(GttColStatsKey *key, Oid relid,
 							  AttrNumber attnum, bool inh);
+static void init_sessions_key(GttSessionsKey *key, Oid relid);
 static void gtt_reset_colstats_for_rel(Oid relid);
 static char *format_stats_values_as_text(AttStatsSlot *sslot);
 static void gtt_xact_callback(XactEvent event, void *arg);
@@ -189,6 +239,16 @@ static void gtt_init_entry(GttStorageEntry *entry, Relation relation);
 static void gtt_build_index_internal(Relation indexRelation, bool force);
 static void gtt_truncate_smgr(GttStorageEntry *entry);
 static void gtt_swap_undo_apply(GttSwapUndo *undo);
+static void gtt_sessions_add(Oid relid, Oid table_relid);
+static void gtt_sessions_remove(Oid relid);
+static ProcNumber gtt_first_other_session_with_storage(Oid relid);
+static ProcNumber gtt_wait_other_sessions_gone(Oid relid);
+static void GttSessionsShmemRequest(void *arg);
+
+const ShmemCallbacks GttSessionsShmemCallbacks = {
+	.request_fn = GttSessionsShmemRequest,
+	/* no init_fn: the hash is populated lazily by backends */
+};
 
 /*
  * ensure_gtt_hash
@@ -319,6 +379,22 @@ GttInitSessionStorage(Relation relation)
 
 	if (!found)
 		gtt_init_entry(entry, relation);
+	else if (!entry->storage_created &&
+			 (entry->locator.relNumber != relation->rd_rel->relfilenode ||
+			  entry->is_index !=
+			  (relation->rd_rel->relkind == RELKIND_INDEX)))
+	{
+		/*
+		 * Stale entry: it caches a locator for a catalog row that no longer
+		 * exists.  This can only happen for an entry without storage -- the
+		 * sessions registry blocks DROP while any session has materialized
+		 * storage -- e.g. this session merely planned a query against a GTT
+		 * that a peer then dropped, and the OID has since been reused for a
+		 * new GTT.  The entry holds no resources (no file, no registry row),
+		 * so simply reinitialize it from the current catalog state.
+		 */
+		gtt_init_entry(entry, relation);
+	}
 
 	/*
 	 * Refresh on_commit_delete from the catalog reloption.  rd_options is not
@@ -327,9 +403,10 @@ GttInitSessionStorage(Relation relation)
 	 * CCI during the same CREATE) supplies the reloption.
 	 *
 	 * The truncation itself is done from PreCommit_gtt_on_commit -- we do not
-	 * register an OnCommitItem because heap_truncate's AccessExclusiveLock
-	 * would conflict with peer sessions' session-level AccessShareLock on the
-	 * same GTT.
+	 * register an OnCommitItem because heap_truncate would escalate to
+	 * AccessExclusiveLock at every commit, blocking on (and conflicting with)
+	 * peers' ordinary transaction-level locks even though only this session's
+	 * private storage is affected.
 	 */
 	if (relation->rd_options != NULL &&
 		relation->rd_rel->relkind == RELKIND_RELATION)
@@ -427,12 +504,13 @@ gtt_init_entry(GttStorageEntry *entry, Relation relation)
 		entry->heap_relid = InvalidOid;
 	entry->index_built = false;
 	entry->build_deferred = false;
-	entry->drop_pending = false;
+	entry->drop_subid = InvalidSubTransactionId;
 	entry->create_subid = GetCurrentSubTransactionId();
 	gtt_xact_state_dirty = true;
 	entry->storage_subid = InvalidSubTransactionId;
 	entry->index_subid = InvalidSubTransactionId;
 	entry->stats_subid = InvalidSubTransactionId;
+	entry->demat_subid = InvalidSubTransactionId;
 	entry->stats_valid = false;
 	entry->relpages = 0;
 	entry->reltuples = 0;
@@ -493,6 +571,9 @@ GttEnsureSessionStorage(Relation relation)
 	entry->storage_subid = GetCurrentSubTransactionId();
 	gtt_xact_state_dirty = true;
 
+	gtt_sessions_add(relid,
+					 entry->is_index ? entry->heap_relid : relid);
+
 	/*
 	 * When a heap materializes -- typically at the top of the first
 	 * heap_insert, before the row is written -- bring its indexes along while
@@ -704,6 +785,14 @@ gtt_remove_entry(GttStorageEntry *entry)
 	/* Discard any per-session column statistics for this relation */
 	gtt_reset_colstats_for_rel(relid);
 
+	/*
+	 * Drop the cross-session registry entry.  A peer's GttCheckDroppable /
+	 * GttCheckAlterable would error out spuriously if it still found us in
+	 * the registry.  (Same ordering as in gtt_session_cleanup; safe if we
+	 * never added an entry.)
+	 */
+	gtt_sessions_remove(relid);
+
 	hash_search(gtt_storage_hash, &relid, HASH_REMOVE, NULL);
 }
 
@@ -734,7 +823,7 @@ GttScheduleDropSessionStorage(Oid relid)
 											NULL);
 	if (entry != NULL)
 	{
-		entry->drop_pending = true;
+		entry->drop_subid = GetCurrentSubTransactionId();
 		gtt_xact_state_dirty = true;
 	}
 }
@@ -824,6 +913,7 @@ gtt_xact_callback(XactEvent event, void *arg)
 	GttStorageEntry *entry;
 	List	   *to_remove = NIL;
 	List	   *to_invalidate = NIL;
+	List	   *to_deregister = NIL;
 	ListCell   *lc;
 
 	if (gtt_storage_hash == NULL)
@@ -862,6 +952,12 @@ gtt_xact_callback(XactEvent event, void *arg)
 		gtt_swap_undo = NIL;
 	}
 
+	/*
+	 * The traversal cannot remove entries inline: hash_seq_search is fragile
+	 * if the current entry is deleted, and gtt_remove_entry may itself need
+	 * to take an LWLock that we don't want to hold across the whole scan.
+	 * Collect victims into to_remove and process them after the scan.
+	 */
 	hash_seq_init(&status, gtt_storage_hash);
 	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
 	{
@@ -870,7 +966,16 @@ gtt_xact_callback(XactEvent event, void *arg)
 		if (event == XACT_EVENT_COMMIT ||
 			event == XACT_EVENT_PARALLEL_COMMIT)
 		{
-			if (entry->drop_pending)
+			/*
+			 * Top-level commit.  An entry with a scheduled drop is removed
+			 * now: heap_drop_with_catalog scheduled the drop, the catalog
+			 * change has just become visible, and the per-session bookkeeping
+			 * must follow.  All other entries survive into subsequent
+			 * transactions; clear the per-subxact bookkeeping since the state
+			 * they reference is now committed and no longer
+			 * rollback-sensitive.
+			 */
+			if (entry->drop_subid != InvalidSubTransactionId)
 				remove = true;
 			else
 			{
@@ -878,18 +983,59 @@ gtt_xact_callback(XactEvent event, void *arg)
 				entry->storage_subid = InvalidSubTransactionId;
 				entry->index_subid = InvalidSubTransactionId;
 				entry->stats_subid = InvalidSubTransactionId;
+
+				/*
+				 * A committed DISCARD releases the (empty) storage and the
+				 * registration; see GttResetAllSessionData.
+				 */
+				if (entry->demat_subid != InvalidSubTransactionId)
+				{
+					entry->demat_subid = InvalidSubTransactionId;
+					if (entry->storage_created)
+					{
+						SMgrRelation srel;
+
+						srel = smgropen(entry->locator,
+										ProcNumberForTempRelations());
+						if (!smgrexists(srel, MAIN_FORKNUM) ||
+							smgrnblocks(srel, MAIN_FORKNUM) == 0)
+						{
+							smgrdounlinkall(&srel, 1, false);
+							entry->storage_created = false;
+							entry->index_built = false;
+							to_deregister = lappend_oid(to_deregister,
+														entry->relid);
+							to_invalidate = lappend_oid(to_invalidate,
+														entry->relid);
+						}
+					}
+				}
 			}
 		}
 		else
 		{
 			/*
-			 * Top-level abort.  Either remove the entry (if it was created in
-			 * this xact) or clear storage_created (if storage was created in
-			 * this xact).  In both cases the per-session file has been
-			 * unlinked by PendingRelDelete and the relcache still has a
-			 * cached rd_locator pointing at it; force a relcache invalidation
-			 * so the next access re-runs RelationInitPhysicalAddr ->
-			 * GttInitSessionStorage and recreates the storage.
+			 * Top-level abort.  Three cases:
+			 *
+			 * 1) The entry was created in this aborting transaction
+			 * (create_subid is set): the catalog row will not exist after
+			 * rollback, so the entry must go away too.  Files for this entry
+			 * are unlinked by the regular PendingRelDelete machinery.
+			 *
+			 * 2) The entry pre-dated this transaction but had its storage or
+			 * index built in it (storage_subid/index_subid set): the catalog
+			 * stays, but the lazily-created files have been unlinked by
+			 * PendingRelDelete.  Reset the storage_created/index_built flags
+			 * so the next access in a later transaction re-creates them.
+			 * Force a relcache invalidation so the next access re-runs
+			 * RelationInitPhysicalAddr -> GttInitSessionStorage; without it
+			 * the cached rd_locator would still point at the now-deleted
+			 * file.
+			 *
+			 * 3) drop_subid is cleared regardless, because any
+			 * heap_drop_with_catalog from this xact is now reverted.
+			 *
+			 * Entries that match (2) or (3) survive the abort.
 			 */
 			if (entry->create_subid != InvalidSubTransactionId)
 			{
@@ -901,6 +1047,7 @@ gtt_xact_callback(XactEvent event, void *arg)
 				if (entry->storage_subid != InvalidSubTransactionId)
 				{
 					gtt_revert_storage(entry);
+					to_deregister = lappend_oid(to_deregister, entry->relid);
 					to_invalidate = lappend_oid(to_invalidate, entry->relid);
 				}
 				if (entry->index_subid != InvalidSubTransactionId)
@@ -922,7 +1069,10 @@ gtt_xact_callback(XactEvent event, void *arg)
 					entry->stats_subid = InvalidSubTransactionId;
 					gtt_reset_colstats_for_rel(entry->relid);
 				}
-				entry->drop_pending = false;
+
+				/* an aborted DISCARD restores the data; cancel the release */
+				entry->demat_subid = InvalidSubTransactionId;
+				entry->drop_subid = InvalidSubTransactionId;
 			}
 		}
 
@@ -932,8 +1082,12 @@ gtt_xact_callback(XactEvent event, void *arg)
 
 	gtt_remove_relids(to_remove);
 
-	foreach(lc, to_invalidate)
-		RelationCacheInvalidateEntry(lfirst_oid(lc));
+	foreach_oid(relid, to_deregister)
+		gtt_sessions_remove(relid);
+	list_free(to_deregister);
+
+	foreach_oid(relid, to_invalidate)
+		RelationCacheInvalidateEntry(relid);
 	list_free(to_invalidate);
 
 	/* every entry has now been settled */
@@ -959,6 +1113,7 @@ gtt_subxact_callback(SubXactEvent event,
 	GttStorageEntry *entry;
 	List	   *to_remove = NIL;
 	List	   *to_invalidate = NIL;
+	List	   *to_deregister = NIL;
 	ListCell   *lc;
 
 	if (gtt_storage_hash == NULL)
@@ -1007,6 +1162,10 @@ gtt_subxact_callback(SubXactEvent event,
 				entry->index_subid = parentSubid;
 			if (entry->stats_subid == mySubid)
 				entry->stats_subid = parentSubid;
+			if (entry->demat_subid == mySubid)
+				entry->demat_subid = parentSubid;
+			if (entry->drop_subid == mySubid)
+				entry->drop_subid = parentSubid;
 		}
 		else					/* SUBXACT_EVENT_ABORT_SUB */
 		{
@@ -1019,6 +1178,7 @@ gtt_subxact_callback(SubXactEvent event,
 			if (entry->storage_subid == mySubid)
 			{
 				gtt_revert_storage(entry);
+				to_deregister = lappend_oid(to_deregister, entry->relid);
 				to_invalidate = lappend_oid(to_invalidate, entry->relid);
 			}
 			if (entry->index_subid == mySubid)
@@ -1033,14 +1193,22 @@ gtt_subxact_callback(SubXactEvent event,
 				entry->stats_subid = InvalidSubTransactionId;
 				gtt_reset_colstats_for_rel(entry->relid);
 			}
+			if (entry->demat_subid == mySubid)
+				entry->demat_subid = InvalidSubTransactionId;
+			if (entry->drop_subid == mySubid)
+				entry->drop_subid = InvalidSubTransactionId;
 		}
 	}
 
 	gtt_remove_relids(to_remove);
 
+	foreach_oid(relid, to_deregister)
+		gtt_sessions_remove(relid);
+	list_free(to_deregister);
+
 	/* See gtt_xact_callback: invalidate relcache for any killed storage. */
-	foreach(lc, to_invalidate)
-		RelationCacheInvalidateEntry(lfirst_oid(lc));
+	foreach_oid(relid, to_invalidate)
+		RelationCacheInvalidateEntry(relid);
 	list_free(to_invalidate);
 }
 
@@ -1790,6 +1958,26 @@ GttResetAllSessionData(void)
 		}
 	}
 	list_free(relids);
+
+	/*
+	 * Schedule dematerialization at commit: once a DISCARD commits, the
+	 * session holds no live data, so it should stop blocking peer DDL -- the
+	 * registry entry, the (now empty) files, and the storage flag are all
+	 * released by the commit callback.  The deferral keeps DISCARD TEMP
+	 * transactional: an abort restores the data through the swap undo, and
+	 * the schedule is simply cancelled.  At commit, an entry whose main fork
+	 * is no longer empty (the same transaction wrote into it after the
+	 * DISCARD) is kept materialized instead.
+	 */
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->storage_created)
+		{
+			entry->demat_subid = GetCurrentSubTransactionId();
+			gtt_xact_state_dirty = true;
+		}
+	}
 }
 
 /*
@@ -1811,12 +1999,35 @@ gtt_session_cleanup(int code, Datum arg)
 	 * The column-stats hash lives in TopMemoryContext and will be torn down
 	 * with the rest of process memory shortly after we return; nothing to do
 	 * here.  We only walk gtt_storage_hash because each entry owns
-	 * externally-visible resources (on-disk files and a session lock) that
-	 * must be released explicitly.
+	 * externally-visible resources (on-disk files and a shared registry
+	 * entry) that must be released explicitly.
 	 */
 	if (gtt_storage_hash == NULL)
 		return;
 
+	/*
+	 * Drop registry entries in a separate first pass before any per-entry
+	 * work that touches the disk.  A peer scanning the registry concurrently
+	 * must not name this backend once we have effectively departed; without
+	 * this ordering the per-entry unlink can stretch that visibility window
+	 * long enough for a peer's GttCheckAlterable / Droppable to error out
+	 * spuriously while we are merely finishing teardown.  See the matching
+	 * ordering in gtt_remove_entry.
+	 *
+	 * This ordering is deliberately not covered by an automated regression
+	 * test.  Reproducing the window requires pausing a backend between the
+	 * registry pass and the lock release while a peer observes, and this code
+	 * runs from before_shmem_exit during proc_exit: an injection-point wait
+	 * placed here cannot be woken once the peer's CREATE INDEX delivers a
+	 * sinval to the parked backend (its condition-variable wakeup is lost),
+	 * so such a test cannot tear down cleanly.  The fix was instead verified
+	 * by hand: with the old ordering and a temporary delay inserted here, a
+	 * peer CREATE INDEX failed ~20/20 runs; with this ordering, 0/20.
+	 */
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+		gtt_sessions_remove(entry->relid);
+
 	hash_seq_init(&status, gtt_storage_hash);
 	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
 	{
@@ -1830,6 +2041,212 @@ gtt_session_cleanup(int code, Datum arg)
 	}
 }
 
+/*
+ * init_sessions_key
+ *		Build a sessions-registry key with deterministic byte contents.
+ *
+ * Like init_colstats_key, we zero the full struct so that HASH_BLOBS hashing
+ * over any trailing alignment padding is reproducible.
+ */
+static void
+init_sessions_key(GttSessionsKey *key, Oid relid)
+{
+	memset(key, 0, sizeof(*key));
+	key->dbOid = MyDatabaseId;
+	key->relid = relid;
+	key->procnum = MyProcNumber;
+}
+
+/*
+ * gtt_sessions_add
+ *		Record that this backend has live per-session storage for a GTT.
+ *
+ * table_relid is the owning table (self for a heap, the parent heap for an
+ * index), so that a DDL check on the table also finds sessions whose only
+ * materialized storage is one of its indexes.
+ */
+static void
+gtt_sessions_add(Oid relid, Oid table_relid)
+{
+	GttSessionsKey key;
+	GttSessionsEntry *entry;
+
+	/* GttSessionsHash is NULL in bootstrap and single-user mode */
+	if (GttSessionsHash == NULL)
+		return;
+
+	init_sessions_key(&key, relid);
+
+	LWLockAcquire(GttSessionsLock, LW_EXCLUSIVE);
+	entry = (GttSessionsEntry *) hash_search(GttSessionsHash, &key,
+											 HASH_ENTER, NULL);
+	entry->table_relid = table_relid;
+	LWLockRelease(GttSessionsLock);
+}
+
+/*
+ * gtt_sessions_remove
+ *		Clear our registry entry for a GTT.  Safe to call if no entry exists.
+ */
+static void
+gtt_sessions_remove(Oid relid)
+{
+	GttSessionsKey key;
+
+	if (GttSessionsHash == NULL)
+		return;
+
+	init_sessions_key(&key, relid);
+
+	LWLockAcquire(GttSessionsLock, LW_EXCLUSIVE);
+	(void) hash_search(GttSessionsHash, &key, HASH_REMOVE, NULL);
+	LWLockRelease(GttSessionsLock);
+}
+
+/*
+ * gtt_first_other_session_with_storage
+ *		Find any peer backend currently holding per-session storage for relid.
+ *
+ * Returns the ProcNumber of one such backend, or INVALID_PROC_NUMBER if none
+ * exists.  Our own entry (if any) is skipped: a session is always allowed
+ * to drop or alter its own live GTT.
+ *
+ * Callers must already hold AccessExclusiveLock on the relation, which keeps
+ * new sessions out of GttInitSessionStorage for this relid; that makes the
+ * shared-mode scan sufficient -- any backend listed here has already
+ * committed to private per-session storage and will not clean it up until
+ * its own exit.
+ */
+static ProcNumber
+gtt_first_other_session_with_storage(Oid relid)
+{
+	HASH_SEQ_STATUS status;
+	GttSessionsEntry *entry;
+	ProcNumber	other_procnum = INVALID_PROC_NUMBER;
+
+	if (GttSessionsHash == NULL)
+		return INVALID_PROC_NUMBER;
+
+	LWLockAcquire(GttSessionsLock, LW_SHARED);
+	hash_seq_init(&status, GttSessionsHash);
+	while ((entry = (GttSessionsEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->key.dbOid == MyDatabaseId &&
+			(entry->key.relid == relid || entry->table_relid == relid) &&
+			entry->key.procnum != MyProcNumber)
+		{
+			other_procnum = entry->key.procnum;
+			hash_seq_term(&status);
+			break;
+		}
+	}
+	LWLockRelease(GttSessionsLock);
+
+	return other_procnum;
+}
+
+/*
+ * gtt_wait_other_sessions_gone
+ *		Wait briefly for other backends' registry entries on relid to clear.
+ *
+ * A backend that disconnects removes its registry entries from
+ * before_shmem_exit (gtt_session_cleanup), but a client can reconnect and
+ * issue DDL before its previous backend has finished exiting.  Erroring
+ * out immediately on such an entry would make patterns like reconnect-
+ * then-DROP fail spuriously.  As with DROP DATABASE's
+ * CountOtherDBBackends(), retry for a few seconds to give exiting
+ * backends time to finish; a backend that is genuinely retaining data
+ * keeps its entry indefinitely, so we then report it.
+ *
+ * Returns INVALID_PROC_NUMBER if no other session has storage for relid
+ * (possibly after waiting), else the proc number of one that does.
+ */
+static ProcNumber
+gtt_wait_other_sessions_gone(Oid relid)
+{
+	ProcNumber	other_procnum = INVALID_PROC_NUMBER;
+	int			tries;
+
+	/* 50 tries with 100ms sleep between tries, i.e. 5 seconds in total */
+	for (tries = 0; tries < 50; tries++)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		other_procnum = gtt_first_other_session_with_storage(relid);
+		if (other_procnum == INVALID_PROC_NUMBER)
+			return INVALID_PROC_NUMBER;
+
+		pg_usleep(100 * 1000L); /* 100ms */
+	}
+
+	return other_procnum;
+}
+
+/*
+ * GttCheckDroppable
+ *		Error out if any other backend has live per-session storage for relid.
+ *
+ * Called from heap_drop_with_catalog.  See
+ * gtt_first_other_session_with_storage() for locking expectations.
+ */
+void
+GttCheckDroppable(Oid relid)
+{
+	ProcNumber	other_procnum = gtt_wait_other_sessions_gone(relid);
+
+	if (other_procnum != INVALID_PROC_NUMBER)
+		ereport(ERROR,
+				errcode(ERRCODE_OBJECT_IN_USE),
+				errmsg("cannot drop global temporary table: another session has live per-session data"),
+				errdetail("Backend with proc number %d has live per-session data.",
+						  other_procnum));
+}
+
+/*
+ * GttCheckAlterable
+ *		Error out if any other backend has live per-session storage for relid.
+ *
+ * Used to gate ALTER TABLE and CREATE INDEX on a GTT.  Without this check,
+ * a peer session with committed data but no transaction in progress holds
+ * no heavyweight lock on the GTT, so nothing else would stop schema changes
+ * that invalidate that session's data (e.g. SET NOT NULL with NULL rows,
+ * ADD UNIQUE with duplicates).  See
+ * gtt_first_other_session_with_storage() for locking expectations.
+ */
+void
+GttCheckAlterable(Oid relid)
+{
+	ProcNumber	other_procnum = gtt_wait_other_sessions_gone(relid);
+
+	if (other_procnum != INVALID_PROC_NUMBER)
+		ereport(ERROR,
+				errcode(ERRCODE_OBJECT_IN_USE),
+				errmsg("cannot alter global temporary table: another session has live per-session data"),
+				errdetail("Backend with proc number %d has live per-session data.",
+						  other_procnum));
+}
+
+/*
+ * GttSessionsShmemRequest
+ *		Register the shared-memory hash at postmaster startup.
+ *
+ * The per-backend estimate is deliberately modest; extending it would only
+ * matter for workloads that routinely touch a large number of GTTs per
+ * session.  Hash growth beyond nelems is allowed by dynahash but forces
+ * linear-probe segment splits, so oversizing slightly is cheap.
+ */
+static void
+GttSessionsShmemRequest(void *arg)
+{
+	ShmemRequestHash(.name = "GTT Sessions",
+					 .nelems = mul_size(MaxBackends,
+										GTT_SESSIONS_ENTRIES_PER_BACKEND),
+					 .ptr = &GttSessionsHash,
+					 .hash_info.keysize = sizeof(GttSessionsKey),
+					 .hash_info.entrysize = sizeof(GttSessionsEntry),
+					 .hash_flags = HASH_ELEM | HASH_BLOBS);
+}
+
 /*
  * format_stats_values_as_text
  *		Convert the values from an AttStatsSlot into a text representation.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 75aa1c213ed..71dae783f76 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -5113,6 +5113,19 @@ ATController(AlterTableStmt *parsetree,
 	List	   *wqueue = NIL;
 	ListCell   *lcmd;
 
+	/*
+	 * For a global temporary table, refuse the ALTER TABLE outright if any
+	 * peer session has live per-session storage.  Their data was written
+	 * against the existing schema, so a column type change, NOT NULL flip,
+	 * new check constraint, unique/primary key, etc., could invalidate it.
+	 * The session-level AccessShareLock acquired in GttInitSessionStorage is
+	 * dropped by LockReleaseAll(allLocks=true) on a peer's transaction abort,
+	 * so the lock alone cannot keep us out -- the shared sessions registry
+	 * does.
+	 */
+	if (RelationIsGlobalTemp(rel))
+		GttCheckAlterable(RelationGetRelid(rel));
+
 	/* Phase 1: preliminary examination of commands, create work queue */
 	foreach(lcmd, cmds)
 	{
diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt
index 560659f9568..c3d8f6bf340 100644
--- a/src/backend/utils/activity/wait_event_names.txt
+++ b/src/backend/utils/activity/wait_event_names.txt
@@ -370,6 +370,7 @@ WaitLSN	"Waiting to read or update shared Wait-for-LSN state."
 LogicalDecodingControl	"Waiting to read or update logical decoding status information."
 DataChecksumsWorker	"Waiting for data checksums worker."
 AioWorkerControl	"Waiting to update AIO worker information."
+GttSessions	"Waiting to read or update the global temporary table sessions registry."
 
 #
 # END OF PREDEFINED LWLOCKS (DO NOT CHANGE THIS LINE)
diff --git a/src/include/catalog/storage_gtt.h b/src/include/catalog/storage_gtt.h
index 92a4731eb31..d435ef49399 100644
--- a/src/include/catalog/storage_gtt.h
+++ b/src/include/catalog/storage_gtt.h
@@ -13,6 +13,7 @@
 #ifndef STORAGE_GTT_H
 #define STORAGE_GTT_H
 
+#include "storage/shmem.h"
 #include "utils/rel.h"
 
 extern void GttInitSessionStorage(Relation relation);
@@ -28,6 +29,17 @@ extern void GttPrepareIndexAccess(Relation indexRelation);
 extern void PreCommit_gtt_on_commit(void);
 extern void GttResetAllSessionData(void);
 
+/*
+ * Cross-session sessions registry for DDL safety.  Backends that create
+ * per-session GTT storage register themselves in a shared hash; DROP TABLE,
+ * ALTER TABLE and CREATE INDEX consult it and error out if any other
+ * session has live data.  No session-level heavyweight lock is taken for a
+ * GTT, so the registry is the sole cross-session guard.
+ */
+extern PGDLLIMPORT const ShmemCallbacks GttSessionsShmemCallbacks;
+extern void GttCheckDroppable(Oid relid);
+extern void GttCheckAlterable(Oid relid);
+
 /* Per-session relation-level statistics for planner */
 extern bool GttGetSessionStats(Oid relid, BlockNumber *relpages,
 							   double *reltuples, BlockNumber *relallvisible);
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index d7eb648bd27..ba30b7955d0 100644
--- a/src/include/storage/lwlocklist.h
+++ b/src/include/storage/lwlocklist.h
@@ -89,6 +89,7 @@ PG_LWLOCK(54, WaitLSN)
 PG_LWLOCK(55, LogicalDecodingControl)
 PG_LWLOCK(56, DataChecksumsWorker)
 PG_LWLOCK(57, AioWorkerControl)
+PG_LWLOCK(58, GttSessions)
 
 /*
  * There also exist several built-in LWLock tranches.  As with the predefined
diff --git a/src/include/storage/subsystemlist.h b/src/include/storage/subsystemlist.h
index 9ad619080be..8035cc2c365 100644
--- a/src/include/storage/subsystemlist.h
+++ b/src/include/storage/subsystemlist.h
@@ -75,6 +75,7 @@ PG_SHMEM_SUBSYSTEM(SlotSyncShmemCallbacks)
 
 /* other modules that need some shared memory space */
 PG_SHMEM_SUBSYSTEM(BTreeShmemCallbacks)
+PG_SHMEM_SUBSYSTEM(GttSessionsShmemCallbacks)
 PG_SHMEM_SUBSYSTEM(SyncScanShmemCallbacks)
 PG_SHMEM_SUBSYSTEM(AsyncShmemCallbacks)
 PG_SHMEM_SUBSYSTEM(StatsShmemCallbacks)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index d2b5c80aee5..1ad5bf46156 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1189,6 +1189,8 @@ GroupingSetKind
 GroupingSetsPath
 GttColStatsEntry
 GttColStatsKey
+GttSessionsEntry
+GttSessionsKey
 GttStorageEntry
 GttSwapUndo
 GucAction
-- 
2.43.0



  [text/x-patch] 0008-Global-temporary-tables-utility-command-restrictions.patch (24.8K, ../[email protected]/9-0008-Global-temporary-tables-utility-command-restrictions.patch)
  download | inline diff:
From a9540bbfb59f8d957b49ed6bdbdb6098f1fe4e45 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Sun, 15 Mar 2026 10:32:39 -0400
Subject: [PATCH 08/12] Global temporary tables: utility command restrictions

Restrict utility commands that are unsafe for global temporary tables.

CLUSTER and REINDEX are blocked because they assign a new relfilenode
in the shared catalog via RelationSetNewRelfilenumber, which would
desynchronize per-session storage mappings.

CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY fall back to their
non-concurrent equivalents, since the multi-transaction protocol would
operate on shared catalog state without matching per-session data.
This mirrors the existing behavior for regular temporary tables.

VACUUM on a GTT skips the vacuum phase -- GTT data lives in
per-session local buffers with no shared freeze state, so the vacuum
machinery (which assumes valid relfrozenxid/relminmxid) cannot safely
process it.  The skip is reported at INFO for a standalone VACUUM and
at DEBUG1 when the command also requests ANALYZE (to avoid spamming
the user during VACUUM (ANALYZE), which is usually what they actually
wanted).  VACUUM (ANALYZE) still runs the analyze phase on the
relation.
---
 src/backend/catalog/index.c              | 12 ++++
 src/backend/commands/indexcmds.c         | 77 +++++++++++++++++++++---
 src/backend/commands/repack.c            | 45 ++++++++++++++
 src/backend/commands/statscmds.c         | 13 ++++
 src/backend/commands/tablecmds.c         | 19 +++++-
 src/backend/commands/vacuum.c            | 49 +++++++++++++++
 src/backend/parser/parse_utilcmd.c       | 55 +++++++++++------
 src/backend/statistics/attribute_stats.c | 13 ++++
 src/backend/statistics/relation_stats.c  | 10 +++
 src/backend/statistics/stat_utils.c      | 19 ++++++
 src/include/statistics/stat_utils.h      |  1 +
 11 files changed, 284 insertions(+), 29 deletions(-)

diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index cff8d39fb44..c234cfb8398 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -52,6 +52,7 @@
 #include "catalog/pg_trigger.h"
 #include "catalog/pg_type.h"
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "catalog/storage_xlog.h"
 #include "commands/event_trigger.h"
 #include "commands/progress.h"
@@ -3801,6 +3802,17 @@ reindex_index(const ReindexStmt *stmt, Oid indexId,
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("cannot reindex temporary tables of other sessions")));
 
+	/*
+	 * Don't allow reindex on global temporary tables.  REINDEX assigns a new
+	 * relfilenode in the shared catalog via RelationSetNewRelfilenumber,
+	 * which would desynchronize per-session storage mappings in other
+	 * sessions.  GTT indexes are rebuilt lazily per-session as needed.
+	 */
+	if (RelationIsGlobalTemp(iRel))
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("cannot reindex global temporary tables"));
+
 	/*
 	 * Don't allow reindex of an invalid index on TOAST table.  This is a
 	 * leftover from a failed REINDEX CONCURRENTLY, and if rebuilt it would
diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c
index 9ab74c8df0a..0157a8deee4 100644
--- a/src/backend/commands/indexcmds.c
+++ b/src/backend/commands/indexcmds.c
@@ -38,6 +38,7 @@
 #include "catalog/pg_opclass.h"
 #include "catalog/pg_tablespace.h"
 #include "catalog/pg_type.h"
+#include "catalog/storage_gtt.h"
 #include "commands/comment.h"
 #include "commands/defrem.h"
 #include "commands/event_trigger.h"
@@ -556,6 +557,7 @@ DefineIndex(ParseState *pstate,
 			bool quiet)
 {
 	bool		concurrent;
+	char		persistence;
 	char	   *indexRelationName;
 	char	   *accessMethodName;
 	Oid		   *typeIds;
@@ -615,11 +617,41 @@ DefineIndex(ParseState *pstate,
 	 * there's no harm in grabbing a stronger lock, and a non-concurrent DROP
 	 * is more efficient.  Do this before any use of the concurrent option is
 	 * done.
+	 *
+	 * Similarly, global temporary tables have per-session local storage that
+	 * other backends cannot see, so concurrent builds are neither necessary
+	 * nor safe (the multi-transaction protocol would operate on the shared
+	 * catalog without matching per-session data).
 	 */
-	if (stmt->concurrent && get_rel_persistence(tableId) != RELPERSISTENCE_TEMP)
-		concurrent = true;
-	else
-		concurrent = false;
+	persistence = get_rel_persistence(tableId);
+
+	/*
+	 * Tell the user when we've silently downgraded a CONCURRENTLY request for
+	 * a global temporary table.  Scripts that rely on CONCURRENTLY for
+	 * minimum downtime would otherwise have no way to know the fallback
+	 * happened.
+	 */
+	if (stmt->concurrent && persistence == RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(NOTICE,
+				errmsg("CREATE INDEX CONCURRENTLY is not supported for global temporary tables"),
+				errdetail("Falling back to a non-concurrent build."));
+
+	concurrent = stmt->concurrent &&
+		persistence != RELPERSISTENCE_TEMP &&
+		persistence != RELPERSISTENCE_GLOBAL_TEMP;
+
+	/*
+	 * For a direct CREATE INDEX on a global temporary table, refuse the
+	 * command if any peer session has live per-session data.  A new UNIQUE
+	 * index on a column with cross-session duplicates would later make the
+	 * peer's data inaccessible (its lazy build would fail with a
+	 * duplicate-key error); even a non-unique index changes planner choices
+	 * in surprising ways for the peer.  ALTER TABLE ADD CONSTRAINT reaches
+	 * DefineIndex with is_alter_table = true, and ATController has already
+	 * done the same check.
+	 */
+	if (!is_alter_table && persistence == RELPERSISTENCE_GLOBAL_TEMP)
+		GttCheckAlterable(tableId);
 
 	/*
 	 * Start progress report.  If we're building a partition, this was already
@@ -2981,12 +3013,19 @@ ReindexIndex(const ReindexStmt *stmt, const ReindexParams *params, bool isTopLev
 	if (relkind == RELKIND_PARTITIONED_INDEX)
 		ReindexPartitions(stmt, indOid, params, isTopLevel);
 	else if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
-			 persistence != RELPERSISTENCE_TEMP)
+			 persistence != RELPERSISTENCE_TEMP &&
+			 persistence != RELPERSISTENCE_GLOBAL_TEMP)
 		ReindexRelationConcurrently(stmt, indOid, params);
 	else
 	{
 		ReindexParams newparams = *params;
 
+		if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
+			persistence == RELPERSISTENCE_GLOBAL_TEMP)
+			ereport(NOTICE,
+					errmsg("REINDEX CONCURRENTLY is not supported for global temporary tables"),
+					errdetail("Falling back to a non-concurrent reindex."));
+
 		newparams.options |= REINDEXOPT_REPORT_PROGRESS;
 		reindex_index(stmt, indOid, false, persistence, &newparams);
 	}
@@ -3077,6 +3116,7 @@ static Oid
 ReindexTable(const ReindexStmt *stmt, const ReindexParams *params, bool isTopLevel)
 {
 	Oid			heapOid;
+	char		persistence;
 	bool		result;
 	const RangeVar *relation = stmt->relation;
 
@@ -3094,10 +3134,13 @@ ReindexTable(const ReindexStmt *stmt, const ReindexParams *params, bool isTopLev
 									   0,
 									   RangeVarCallbackMaintainsTable, NULL);
 
+	persistence = get_rel_persistence(heapOid);
+
 	if (get_rel_relkind(heapOid) == RELKIND_PARTITIONED_TABLE)
 		ReindexPartitions(stmt, heapOid, params, isTopLevel);
 	else if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
-			 get_rel_persistence(heapOid) != RELPERSISTENCE_TEMP)
+			 persistence != RELPERSISTENCE_TEMP &&
+			 persistence != RELPERSISTENCE_GLOBAL_TEMP)
 	{
 		result = ReindexRelationConcurrently(stmt, heapOid, params);
 
@@ -3110,6 +3153,12 @@ ReindexTable(const ReindexStmt *stmt, const ReindexParams *params, bool isTopLev
 	{
 		ReindexParams newparams = *params;
 
+		if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
+			persistence == RELPERSISTENCE_GLOBAL_TEMP)
+			ereport(NOTICE,
+					errmsg("REINDEX CONCURRENTLY is not supported for global temporary tables"),
+					errdetail("Falling back to a non-concurrent reindex."));
+
 		newparams.options |= REINDEXOPT_REPORT_PROGRESS;
 		result = reindex_relation(stmt, heapOid,
 								  REINDEX_REL_PROCESS_TOAST |
@@ -3250,6 +3299,14 @@ ReindexMultipleTables(const ReindexStmt *stmt, const ReindexParams *params)
 			!isTempNamespace(classtuple->relnamespace))
 			continue;
 
+		/*
+		 * Skip global temporary tables; they cannot be reindexed (see
+		 * reindex_index()), so we silently skip them here to avoid aborting a
+		 * database- or schema-wide REINDEX.
+		 */
+		if (classtuple->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			continue;
+
 		/*
 		 * Check user/system classification.  SYSTEM processes all the
 		 * catalogs, and DATABASE processes everything that's not a catalog.
@@ -3521,7 +3578,8 @@ ReindexMultipleInternal(const ReindexStmt *stmt, const List *relids, const Reind
 		Assert(!RELKIND_HAS_PARTITIONS(relkind));
 
 		if ((params->options & REINDEXOPT_CONCURRENTLY) != 0 &&
-			relpersistence != RELPERSISTENCE_TEMP)
+			relpersistence != RELPERSISTENCE_TEMP &&
+			relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
 		{
 			ReindexParams newparams = *params;
 
@@ -3962,8 +4020,9 @@ ReindexRelationConcurrently(const ReindexStmt *stmt, Oid relationOid, const Rein
 		idx->tableId = RelationGetRelid(heapRel);
 		idx->amId = indexRel->rd_rel->relam;
 
-		/* This function shouldn't be called for temporary relations. */
-		if (indexRel->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
+		/* This function shouldn't be called for temporary or GTT relations. */
+		if (indexRel->rd_rel->relpersistence == RELPERSISTENCE_TEMP ||
+			RelationIsGlobalTemp(indexRel))
 			elog(ERROR, "cannot reindex a temporary table concurrently");
 
 		pgstat_progress_start_command(PROGRESS_COMMAND_CREATE_INDEX, idx->tableId);
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 4d177c868bb..cf0808df2de 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -48,6 +48,7 @@
 #include "catalog/namespace.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_am.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_constraint.h"
 #include "catalog/pg_inherits.h"
 #include "catalog/toasting.h"
@@ -601,6 +602,18 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 				errmsg("cannot execute %s on temporary tables of other sessions",
 					   RepackCommandAsString(cmd)));
 
+	/*
+	 * Global temporary tables cannot be repacked or clustered because these
+	 * operations assign a new relfilenode in the shared catalog, which would
+	 * desynchronize per-session storage mappings.
+	 */
+	if (RelationIsGlobalTemp(OldHeap))
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+		/*- translator: first %s is name of a SQL command, eg. REPACK */
+				errmsg("cannot execute %s on global temporary tables",
+					   RepackCommandAsString(cmd)));
+
 	/*
 	 * Also check for active uses of the relation in the current transaction,
 	 * including open scans and pending AFTER trigger events.
@@ -2186,6 +2199,18 @@ get_tables_to_repack(RepackCommand cmd, bool usingindex, MemoryContext permcxt)
 				continue;
 			}
 
+			/*
+			 * Skip global temporary tables; they cannot be repacked or
+			 * clustered (see cluster_rel()).  Aborting a database-wide REPACK
+			 * on every GTT in the catalog would be unhelpful.
+			 */
+			if (classForm->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			{
+				ReleaseSysCache(classtup);
+				UnlockRelationOid(index->indrelid, AccessShareLock);
+				continue;
+			}
+
 			ReleaseSysCache(classtup);
 
 			/* noisily skip rels which the user can't process */
@@ -2250,6 +2275,13 @@ get_tables_to_repack(RepackCommand cmd, bool usingindex, MemoryContext permcxt)
 				continue;
 			}
 
+			/* See the matching skip in the USING INDEX branch above. */
+			if (class->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			{
+				UnlockRelationOid(class->oid, AccessShareLock);
+				continue;
+			}
+
 			/* noisily skip rels which the user can't process */
 			if (!repack_is_permitted_for_relation(cmd, class->oid,
 												  GetUserId()))
@@ -2410,6 +2442,19 @@ process_single_relation(RepackStmt *stmt, LOCKMODE lockmode, bool isTopLevel,
 				errmsg("cannot execute %s on temporary tables of other sessions",
 					   RepackCommandAsString(stmt->command)));
 
+	/*
+	 * Reject GTTs here as well as in cluster_rel(): for a partitioned GTT we
+	 * return below without calling cluster_rel(), so the caller would
+	 * otherwise iterate the partitions and produce a less helpful error
+	 * naming one of them.
+	 */
+	if (RelationIsGlobalTemp(rel))
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+		/*- translator: first %s is name of a SQL command, eg. REPACK */
+				errmsg("cannot execute %s on global temporary tables",
+					   RepackCommandAsString(stmt->command)));
+
 	/*
 	 * For partitioned tables, let caller handle this.  Otherwise, process it
 	 * here and we're done.
diff --git a/src/backend/commands/statscmds.c b/src/backend/commands/statscmds.c
index b354723be44..8b32d0f4110 100644
--- a/src/backend/commands/statscmds.c
+++ b/src/backend/commands/statscmds.c
@@ -136,6 +136,19 @@ CreateStatistics(CreateStatsStmt *stmt, bool check_rights)
 							RelationGetRelationName(rel)),
 					 errdetail_relkind_not_supported(rel->rd_rel->relkind)));
 
+		/*
+		 * Extended statistics are stored in the shared pg_statistic_ext_data
+		 * catalog, but global temporary tables have per-session data, so any
+		 * stats gathered by ANALYZE would reflect a single session's sample
+		 * and be misleading for others.  Reject the command outright rather
+		 * than silently accepting a definition that will never be populated.
+		 */
+		if (RelationIsGlobalTemp(rel))
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("cannot define statistics for global temporary table \"%s\"",
+						   RelationGetRelationName(rel)));
+
 		/*
 		 * You must own the relation to create stats on it.
 		 *
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 71dae783f76..3f6995b0f75 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -1765,9 +1765,26 @@ RemoveRelations(DropStmt *drop)
 		/*
 		 * Decide if concurrent mode needs to be used here or not.  The
 		 * callback retrieved the rel's persistence for us.
+		 *
+		 * Global temporary tables have per-session local storage that other
+		 * backends cannot see, so the multi-transaction concurrent protocol
+		 * is neither necessary nor safe.  Mirror the CREATE INDEX
+		 * CONCURRENTLY fallback in indexcmds.c: emit a NOTICE and proceed
+		 * with a non-concurrent drop, upgrading our lock from
+		 * ShareUpdateExclusiveLock to AccessExclusiveLock.  The final
+		 * heap_drop_with_catalog still passes through GttCheckDroppable, so
+		 * peers with live per-session storage block the drop either way.
 		 */
 		if (drop->concurrent &&
-			state.actual_relpersistence != RELPERSISTENCE_TEMP)
+			state.actual_relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		{
+			ereport(NOTICE,
+					errmsg("DROP INDEX CONCURRENTLY is not supported for global temporary tables"),
+					errdetail("Falling back to a non-concurrent drop."));
+			LockRelationOid(relOid, AccessExclusiveLock);
+		}
+		else if (drop->concurrent &&
+				 state.actual_relpersistence != RELPERSISTENCE_TEMP)
 		{
 			Assert(list_length(drop->objects) == 1 &&
 				   drop->removeType == OBJECT_INDEX);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index a4abb29cf64..ab0b1c493f7 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -35,6 +35,7 @@
 #include "access/transam.h"
 #include "access/xact.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_inherits.h"
 #include "commands/async.h"
@@ -1068,6 +1069,16 @@ get_all_vacuum_rels(MemoryContext vac_context, int options)
 			!isTempOrTempToastNamespace(classForm->relnamespace))
 			continue;
 
+		/*
+		 * Skip global temporary tables.  vacuum_rel() would skip them anyway,
+		 * but doing so silently here avoids emitting a stream of INFO
+		 * "skipping vacuum" messages on a database-wide VACUUM. A VACUUM that
+		 * names a GTT explicitly still reaches vacuum_rel() and is reported
+		 * there.
+		 */
+		if (classForm->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			continue;
+
 		/* check permissions of relation */
 		if (!vacuum_is_permitted_for_relation(relid, classForm, options))
 			continue;
@@ -1118,6 +1129,21 @@ vacuum_get_cutoffs(Relation rel, const VacuumParams *params,
 				safeOldestMxact,
 				aggressiveMXIDCutoff;
 
+	/*
+	 * Global temporary tables have session-local storage and carry invalid
+	 * relfrozenxid/relminmxid in their shared pg_class row, so they must
+	 * never reach the freeze machinery.  Every caller is expected to skip
+	 * them well before this point (see vacuum_rel() and cluster_rel());
+	 * arriving here with one means a skip was missed.  Computing cutoffs
+	 * would either trip the downstream
+	 * TransactionIdIsNormal()/MultiXactIdIsValid() assertions or silently
+	 * freeze session-local data against bogus limits, so error out loudly
+	 * instead.
+	 */
+	if (RelationIsGlobalTemp(rel))
+		elog(ERROR, "cannot compute freeze cutoffs for global temporary table \"%s\"",
+			 RelationGetRelationName(rel));
+
 	/* Use mutable copies of freeze age parameters */
 	freeze_min_age = params->freeze_min_age;
 	multixact_freeze_min_age = params->multixact_freeze_min_age;
@@ -2155,6 +2181,29 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 		return false;
 	}
 
+	/*
+	 * Skip the vacuum portion for global temporary tables.  GTT data lives in
+	 * per-session local buffers with no shared freeze state, so the vacuum
+	 * machinery (which assumes valid relfrozenxid/relminmxid) cannot safely
+	 * process them.
+	 *
+	 * If ANALYZE was requested in the same command (VACUUM (ANALYZE)), we
+	 * return true so the caller still runs analyze_rel on this relation.
+	 * Otherwise we return false to short-circuit completely.
+	 */
+	if (RelationIsGlobalTemp(rel))
+	{
+		bool		can_analyze = (params.options & VACOPT_ANALYZE) != 0;
+
+		ereport(can_analyze ? DEBUG1 : INFO,
+				errmsg("skipping vacuum of \"%s\" --- data is session-local for a global temporary table",
+					   RelationGetRelationName(rel)));
+		relation_close(rel, lmode);
+		PopActiveSnapshot();
+		CommitTransactionCommand();
+		return can_analyze;
+	}
+
 	/*
 	 * Silently ignore partitioned tables as there is no work to be done.  The
 	 * useful work is on their child partitions, which have been queued up for
diff --git a/src/backend/parser/parse_utilcmd.c b/src/backend/parser/parse_utilcmd.c
index a049cc67ed6..592633a2eaa 100644
--- a/src/backend/parser/parse_utilcmd.c
+++ b/src/backend/parser/parse_utilcmd.c
@@ -1592,29 +1592,46 @@ expandTableLikeClause(RangeVar *heapRel, TableLikeClause *table_like_clause)
 
 		parent_extstats = RelationGetStatExtList(relation);
 
-		foreach(l, parent_extstats)
+		/*
+		 * Global temporary tables cannot have extended statistics: their data
+		 * is per-session, but pg_statistic_ext_data is shared, so any stats
+		 * would reflect a single session's sample and mislead others (see
+		 * CreateStatistics).  Rather than fail outright, skip cloning the
+		 * parent's statistics objects, but warn so the omission is not
+		 * silent.
+		 */
+		if (parent_extstats != NIL && RelationIsGlobalTemp(childrel))
+			ereport(WARNING,
+					(errmsg("statistics objects not copied to global temporary table \"%s\"",
+							RelationGetRelationName(childrel)),
+					 errdetail("Global temporary tables cannot have extended statistics.")));
+		else
 		{
-			Oid			parent_stat_oid = lfirst_oid(l);
-			CreateStatsStmt *stats_stmt;
-
-			stats_stmt = generateClonedExtStatsStmt(heapRel,
-													RelationGetRelid(childrel),
-													parent_stat_oid,
-													attmap);
-
-			/* Copy comment on statistics object, if requested */
-			if (table_like_clause->options & CREATE_TABLE_LIKE_COMMENTS)
+			foreach(l, parent_extstats)
 			{
-				comment = GetComment(parent_stat_oid, StatisticExtRelationId, 0);
+				Oid			parent_stat_oid = lfirst_oid(l);
+				CreateStatsStmt *stats_stmt;
 
-				/*
-				 * We make use of CreateStatsStmt's stxcomment option, so as
-				 * not to need to know now what name the statistics will have.
-				 */
-				stats_stmt->stxcomment = comment;
+				stats_stmt = generateClonedExtStatsStmt(heapRel,
+														RelationGetRelid(childrel),
+														parent_stat_oid,
+														attmap);
+
+				/* Copy comment on statistics object, if requested */
+				if (table_like_clause->options & CREATE_TABLE_LIKE_COMMENTS)
+				{
+					comment = GetComment(parent_stat_oid, StatisticExtRelationId, 0);
+
+					/*
+					 * We make use of CreateStatsStmt's stxcomment option, so
+					 * as not to need to know now what name the statistics
+					 * will have.
+					 */
+					stats_stmt->stxcomment = comment;
+				}
+
+				result = lappend(result, stats_stmt);
 			}
-
-			result = lappend(result, stats_stmt);
 		}
 
 		list_free(parent_extstats);
diff --git a/src/backend/statistics/attribute_stats.c b/src/backend/statistics/attribute_stats.c
index 1cc4d657231..48c0bdebd86 100644
--- a/src/backend/statistics/attribute_stats.c
+++ b/src/backend/statistics/attribute_stats.c
@@ -20,6 +20,7 @@
 #include "access/heapam.h"
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_operator.h"
 #include "nodes/makefuncs.h"
 #include "statistics/statistics.h"
@@ -185,6 +186,15 @@ attribute_statistics_update(FunctionCallInfo fcinfo)
 									  ShareUpdateExclusiveLock, 0,
 									  RangeVarCallbackForStats, &locked_table);
 
+	/*
+	 * Reject global temporary tables: ANALYZE on a GTT writes session-private
+	 * column statistics rather than pg_statistic rows, and the planner reads
+	 * them back via SearchStats().  Writing pg_statistic for a GTT
+	 * here would surface in any session that has not yet run ANALYZE (its
+	 * per-session miss would fall back on the syscache).
+	 */
+	stats_check_not_global_temp(reloid, relname);
+
 	/* user can specify either attname or attnum, but not both */
 	if (!PG_ARGISNULL(ATTNAME_ARG))
 	{
@@ -623,6 +633,9 @@ pg_clear_attribute_stats(PG_FUNCTION_ARGS)
 									  ShareUpdateExclusiveLock, 0,
 									  RangeVarCallbackForStats, &locked_table);
 
+	/* See attribute_statistics_update() for the rationale. */
+	stats_check_not_global_temp(reloid, relname);
+
 	attname = TextDatumGetCString(PG_GETARG_DATUM(C_ATTNAME_ARG));
 	attnum = get_attnum(reloid, attname);
 
diff --git a/src/backend/statistics/relation_stats.c b/src/backend/statistics/relation_stats.c
index d6631e9a9a4..3e70bfa989f 100644
--- a/src/backend/statistics/relation_stats.c
+++ b/src/backend/statistics/relation_stats.c
@@ -20,6 +20,7 @@
 #include "access/heapam.h"
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_class.h"
 #include "nodes/makefuncs.h"
 #include "statistics/stat_utils.h"
 #include "utils/builtins.h"
@@ -101,6 +102,15 @@ relation_statistics_update(FunctionCallInfo fcinfo)
 									  ShareUpdateExclusiveLock, 0,
 									  RangeVarCallbackForStats, &locked_table);
 
+	/*
+	 * Reject global temporary tables.  Their data is per-session, so the
+	 * shared pg_class row is never read by the planner for query estimation
+	 * (GttGetSessionStats() supplies session-private values), and writing
+	 * shared values would mislead any session that has not yet run ANALYZE
+	 * because the per-session miss would fall back on these values.
+	 */
+	stats_check_not_global_temp(reloid, relname);
+
 	if (!PG_ARGISNULL(RELPAGES_ARG))
 	{
 		relpages = PG_GETARG_UINT32(RELPAGES_ARG);
diff --git a/src/backend/statistics/stat_utils.c b/src/backend/statistics/stat_utils.c
index a673e3c704b..790c0a5c959 100644
--- a/src/backend/statistics/stat_utils.c
+++ b/src/backend/statistics/stat_utils.c
@@ -46,6 +46,25 @@
 
 static Node *statatt_get_index_expr(Relation rel, int attnum);
 
+/*
+ * stats_check_not_global_temp
+ *		Reject statistics import for a global temporary table.
+ *
+ * A GTT's statistics are per-session (established by running ANALYZE in
+ * each session); its shared pg_class/pg_statistic rows must stay
+ * unpopulated, so the stats import functions cannot apply to it.
+ */
+void
+stats_check_not_global_temp(Oid reloid, const char *relname)
+{
+	if (get_rel_persistence(reloid) == RELPERSISTENCE_GLOBAL_TEMP)
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("cannot modify shared statistics for global temporary table \"%s\"",
+					   relname),
+				errhint("Run ANALYZE in each session that uses the table."));
+}
+
 /*
  * Ensure that a given argument is not null.
  */
diff --git a/src/include/statistics/stat_utils.h b/src/include/statistics/stat_utils.h
index 74da7790579..14baae4ee30 100644
--- a/src/include/statistics/stat_utils.h
+++ b/src/include/statistics/stat_utils.h
@@ -25,6 +25,7 @@ struct StatsArgInfo
 	Oid			argtype;
 };
 
+extern void stats_check_not_global_temp(Oid reloid, const char *relname);
 extern void stats_check_required_arg(FunctionCallInfo fcinfo,
 									 struct StatsArgInfo *arginfo,
 									 int argnum);
-- 
2.43.0



  [text/x-patch] 0009-Global-temporary-tables-guard-session-data-against-X.patch (52.4K, ../[email protected]/10-0009-Global-temporary-tables-guard-session-data-against-X.patch)
  download | inline diff:
From 41d1be72202dd3812eb23b90e99b215fe93853f6 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Tue, 2 Jun 2026 15:43:37 -0400
Subject: [PATCH 09/12] Global temporary tables: guard session data against XID
 wraparound

GTT data is session-local and a GTT contributes nothing to datfrozenxid,
so unless the owning session freezes its own data the cluster can truncate
CLOG and advance past the xmin of a still-live GTT tuple.  A long-lived
session that retains data in an ON COMMIT PRESERVE ROWS GTT could then read
that data either with a "could not access status of transaction" error or,
once the xmin is far enough in the past, with a silently wrong visibility
decision.

Track, per heap relation per session, a conservative lower bound on the
oldest unfrozen xmin (oldest_xid): seeded with the top-level XID on the
first write into empty storage (the top-level XID is assigned before any
subtransaction XID, so a savepoint-first write cannot leave a seed newer
than a later outer-level xmin) and reset whenever the storage is emptied
(TRUNCATE's storage swap, ON COMMIT DELETE ROWS, or abort-discard --
with the swap-undo record restoring it if the truncating transaction
rolls back).  At every heap read and write entry point
-- the relation-aware table-AM callbacks (scan begin, index scan begin,
TID fetch, row lock, get-latest-tid, insert/multi-insert/update/delete) --
refuse access (fail closed) once oldest_xid precedes the cluster
CLOG-truncation horizon (TransamVariables->oldestXid), and warn as it
approaches.  Refusing rather than risking a wrong result converts silent
corruption into a clear error; GTT data is read only by its owning backend
(parallel query is disabled for GTTs), so these entry points are an
exhaustive choke surface.

Give such a session a way out rather than only refusing access: a manual
VACUUM now freezes a GTT's session-local data in place.  The per-session
storage behaves like a regular temp table's, so it can be frozen; only the
bookkeeping differs, because the shared pg_class row is common to all
sessions and cannot hold any one session's freeze horizon.  oldest_xid
doubles as the session relfrozenxid, with a companion session_relminmxid
as its multixact counterpart; vacuum_get_cutoffs() sources a GTT's starting
cutoffs from this session state and vac_update_relstats() writes the new
horizon back there, never touching pg_class.  vacuum_rel() lets a GTT
through to the normal lazy-vacuum path (skipping only when this session
holds no data) and rejects VACUUM FULL, which would reassign the shared
relfilenode.  A VACUUM advances oldest_xid past the horizon, so it is both
the routine maintenance for long-lived sessions and the escape hatch once
the warning fires; VACUUM does not go through the guarded entry points, so
it remains available even after reads have started to fail.  Only if a
never-hinted tuple's CLOG is already gone must the session instead TRUNCATE
or reconnect.

The new global_temp_xid_warn_margin GUC controls the warning head room (in
transactions) before the horizon; the hard error is fixed at the horizon.
The warning is suppressed unless the cluster's CLOG history exceeds the
margin, so it does not fire on freshly written data on young or
aggressively-frozen clusters.
---
 src/backend/access/heap/heapam.c              |  23 +
 src/backend/access/heap/heapam_handler.c      |  10 +
 src/backend/access/index/indexam.c            |  28 +
 src/backend/catalog/storage_gtt.c             | 556 +++++++++++++++++-
 src/backend/commands/vacuum.c                 | 134 +++--
 src/backend/utils/misc/guc_parameters.dat     |   9 +
 src/backend/utils/misc/guc_tables.c           |   1 +
 src/backend/utils/misc/postgresql.conf.sample |   4 +
 src/include/catalog/storage_gtt.h             |  31 +
 9 files changed, 738 insertions(+), 58 deletions(-)

diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index abfd8e8970a..22da4a9c6de 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -42,6 +42,7 @@
 #include "access/xloginsert.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_database_d.h"
+#include "catalog/storage_gtt.h"
 #include "commands/vacuum.h"
 #include "executor/instrument_node.h"
 #include "pgstat.h"
@@ -1171,6 +1172,13 @@ heap_beginscan(Relation relation, Snapshot snapshot,
 {
 	HeapScanDesc scan;
 
+	/*
+	 * Refuse to scan a global temporary table whose data has aged toward the
+	 * transaction-ID horizon.  Checked once here at scan start; that is
+	 * frequent enough since a single statement cannot move the horizon.
+	 */
+	GttPrepareAccess(relation, false);
+
 	/*
 	 * increment relation ref count while scanning relation
 	 *
@@ -1798,6 +1806,9 @@ heap_get_latest_tid(TableScanDesc sscan,
 	ItemPointerData ctid;
 	TransactionId priorXmax;
 
+	/* Refuse to walk a global temporary table whose data has aged out. */
+	GttPrepareAccess(relation, false);
+
 	/*
 	 * table_tuple_get_latest_tid() verified that the passed in tid is valid.
 	 * Assume that t_ctid links are valid however - there shouldn't be invalid
@@ -2011,6 +2022,9 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid,
 	Buffer		vmbuffer = InvalidBuffer;
 	bool		all_visible_cleared = false;
 
+	/* Refuse to extend a global temporary table whose data has aged out. */
+	GttPrepareAccess(relation, true);
+
 	/* Cheap, simplistic check that the tuple matches the rel's rowtype. */
 	Assert(HeapTupleHeaderGetNatts(tup->t_data) <=
 		   RelationGetNumberOfAttributes(relation));
@@ -2300,6 +2314,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
 	/* currently not needed (thus unsupported) for heap_multi_insert() */
 	Assert(!(options & HEAP_INSERT_NO_LOGICAL));
 
+	/* Refuse to extend a global temporary table whose data has aged out. */
+	GttPrepareAccess(relation, true);
+
 	AssertHasSnapshotForToast(relation);
 
 	needwal = RelationNeedsWAL(relation);
@@ -2741,6 +2758,9 @@ heap_delete(Relation relation, const ItemPointerData *tid,
 
 	AssertHasSnapshotForToast(relation);
 
+	/* Refuse to touch a global temporary table whose data has aged out. */
+	GttPrepareAccess(relation, false);
+
 	/*
 	 * Forbid this during a parallel operation, lest it allocate a combo CID.
 	 * Other workers might need that combo CID for visibility checks, and we
@@ -3253,6 +3273,9 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup,
 
 	AssertHasSnapshotForToast(relation);
 
+	/* Refuse to touch a global temporary table whose data has aged out. */
+	GttPrepareAccess(relation, false);
+
 	/*
 	 * Forbid this during a parallel operation, lest it allocate a combo CID.
 	 * Other workers might need that combo CID for visibility checks, and we
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index 2268cc277bc..f046c3ffdeb 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -32,6 +32,7 @@
 #include "catalog/catalog.h"
 #include "catalog/index.h"
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "catalog/storage_xlog.h"
 #include "commands/progress.h"
 #include "executor/executor.h"
@@ -96,6 +97,9 @@ heapam_fetch_row_version(Relation relation,
 
 	Assert(TTS_IS_BUFFERTUPLE(slot));
 
+	/* Refuse to fetch from a global temporary table whose data has aged out. */
+	GttPrepareAccess(relation, false);
+
 	bslot->base.tupdata.t_self = *tid;
 	if (heap_fetch(relation, snapshot, &bslot->base.tupdata, &buffer, false))
 	{
@@ -284,6 +288,12 @@ heapam_tuple_lock(Relation relation, ItemPointer tid, Snapshot snapshot,
 
 	Assert(TTS_IS_BUFFERTUPLE(slot));
 
+	/*
+	 * Refuse to lock rows in a global temporary table whose data has aged
+	 * out.
+	 */
+	GttPrepareAccess(relation, false);
+
 tuple_lock_retry:
 	tuple->t_self = *tid;
 	result = heap_lock_tuple(relation, tuple, cid, mode, wait_policy,
diff --git a/src/backend/access/index/indexam.c b/src/backend/access/index/indexam.c
index 7967e939847..67856a24ca0 100644
--- a/src/backend/access/index/indexam.c
+++ b/src/backend/access/index/indexam.c
@@ -49,7 +49,9 @@
 #include "access/relscan.h"
 #include "access/tableam.h"
 #include "catalog/index.h"
+#include "catalog/pg_class.h"
 #include "catalog/pg_type.h"
+#include "catalog/storage_gtt.h"
 #include "nodes/execnodes.h"
 #include "pgstat.h"
 #include "storage/lmgr.h"
@@ -275,6 +277,13 @@ index_beginscan(Relation heapRelation,
 						RelationGetRelationName(heapRelation))));
 	}
 
+	/*
+	 * Refuse to scan an index on a global temporary table whose heap data has
+	 * aged toward the transaction-ID horizon.  Index scans fetch heap tuples
+	 * for visibility, so gate on the heap relation here at scan start.
+	 */
+	GttPrepareAccess(heapRelation, false);
+
 	scan = index_beginscan_internal(indexRelation, nkeys, norderbys, snapshot, NULL, false);
 
 	/*
@@ -332,6 +341,15 @@ index_beginscan_internal(Relation indexRelation,
 	RELATION_CHECKS;
 	CHECK_REL_PROCEDURE(ambeginscan);
 
+	/*
+	 * A GTT index's per-session storage is materialized and built lazily;
+	 * make sure it exists before the AM reads its metapage.  (The parent heap
+	 * is not materialized by this: building over an unmaterialized heap
+	 * yields an empty, structurally valid index.)
+	 */
+	if (RelationIsGlobalTemp(indexRelation))
+		GttPrepareIndexAccess(indexRelation);
+
 	if (!(indexRelation->rd_indam->ampredlocks))
 		PredicateLockRelation(indexRelation, snapshot);
 
@@ -814,6 +832,16 @@ index_can_return(Relation indexRelation, int attno)
 {
 	RELATION_CHECKS;
 
+	/*
+	 * An unmaterialized GTT index has no pages for amcanreturn to consult
+	 * (SPGiST reads its metapage, for example); the index is empty, so
+	 * "cannot return" is a safe answer that keeps planning from materializing
+	 * per-session storage.
+	 */
+	if (RelationIsGlobalTemp(indexRelation) &&
+		!GttSessionIndexUsable(RelationGetRelid(indexRelation)))
+		return false;
+
 	/* amcanreturn is optional; assume false if not provided by AM */
 	if (indexRelation->rd_indam->amcanreturn == NULL)
 		return false;
diff --git a/src/backend/catalog/storage_gtt.c b/src/backend/catalog/storage_gtt.c
index fa0760707b4..93f21ca8db4 100644
--- a/src/backend/catalog/storage_gtt.c
+++ b/src/backend/catalog/storage_gtt.c
@@ -27,6 +27,8 @@
 #include "access/relation.h"
 #include "access/table.h"
 #include "access/tableam.h"
+#include "access/multixact.h"
+#include "access/transam.h"
 #include "access/xact.h"
 #include "catalog/heap.h"
 #include "catalog/index.h"
@@ -87,6 +89,7 @@ typedef struct GttStorageEntry
 	RelFileLocator locator;		/* per-session physical storage location */
 	bool		storage_created;	/* has smgr file been created? */
 	bool		is_index;		/* is this an index relation? */
+	bool		is_sequence;	/* is this a sequence relation? */
 	bool		index_built;	/* has index been built in this session? */
 	bool		build_deferred; /* index_build deferred the physical build
 								 * because the parent heap was unmaterialized */
@@ -106,6 +109,29 @@ typedef struct GttStorageEntry
 	BlockNumber relpages;		/* per-session page count */
 	float4		reltuples;		/* per-session tuple count */
 	BlockNumber relallvisible;	/* per-session all-visible pages */
+
+	/*
+	 * Transaction-ID horizon tracking (heap entries only).  oldest_xid is a
+	 * conservative lower bound on the oldest unfrozen xmin in this session's
+	 * data for the relation: it is set to the current XID on the first write
+	 * into otherwise-empty storage and reset to InvalidTransactionId whenever
+	 * the storage is emptied (truncate / on-commit-delete / abort-discard).
+	 * Later writes only ever use newer XIDs, and a tuple's xmin cannot be
+	 * older than the storage's first write, so this never overstates how
+	 * recent the data is.  xid_warned throttles the approaching-horizon
+	 * WARNING to once per relation per session.  See GttPrepareAccess().
+	 *
+	 * oldest_xid doubles as the session-local relfrozenxid: a VACUUM of the
+	 * GTT freezes this session's storage in place and advances oldest_xid to
+	 * the oldest unfrozen XID it leaves behind (see
+	 * GttUpdateSessionFrozenXids). session_relminmxid is the matching
+	 * session-local relminmxid; it is set to the current next-multixact on
+	 * the first write and advanced by VACUUM. Neither value can live in the
+	 * shared pg_class row, which is common to all sessions.
+	 */
+	TransactionId oldest_xid;
+	bool		xid_warned;
+	MultiXactId session_relminmxid;
 } GttStorageEntry;
 
 /* Backend-local hash table: GTT OID -> GttStorageEntry */
@@ -140,6 +166,9 @@ typedef struct GttSwapUndo
 	BlockNumber prev_relpages;
 	float4		prev_reltuples;
 	BlockNumber prev_relallvisible;
+	TransactionId prev_oldest_xid;
+	bool		prev_xid_warned;
+	MultiXactId prev_session_relminmxid;
 } GttSwapUndo;
 
 /* List of GttSwapUndo *, newest first, allocated in TopMemoryContext */
@@ -174,6 +203,15 @@ typedef struct GttColStatsEntry
 /* Backend-local hash table: (relid, attnum, inh) -> GttColStatsEntry */
 static HTAB *gtt_colstats_hash = NULL;
 
+/*
+ * GUC: how many transactions of head room to leave before the cluster
+ * CLOG-truncation horizon when warning that a GTT's data is aging toward
+ * transaction-ID wraparound.  The hard error fires at the horizon itself;
+ * this only controls the earlier WARNING.  Generous by default so operators
+ * get ample notice.  See GttPrepareAccess().
+ */
+int			global_temp_xid_warn_margin = 100000000;
+
 /* Guard against recursive index builds */
 static bool gtt_building_index = false;
 
@@ -235,10 +273,15 @@ static void gtt_subxact_callback(SubXactEvent event,
 static void gtt_remove_entry(GttStorageEntry *entry);
 static void gtt_revert_storage(GttStorageEntry *entry);
 static void gtt_remove_relids(List *to_remove);
-static void gtt_init_entry(GttStorageEntry *entry, Relation relation);
-static void gtt_build_index_internal(Relation indexRelation, bool force);
+static void gtt_truncate_dependents(List *heap_relids);
 static void gtt_truncate_smgr(GttStorageEntry *entry);
 static void gtt_swap_undo_apply(GttSwapUndo *undo);
+static void gtt_init_entry(GttStorageEntry *entry, Relation relation);
+static void gtt_build_index_internal(Relation indexRelation, bool force);
+#ifdef USE_ASSERT_CHECKING
+static bool gtt_session_registered(Oid relid);
+static void gtt_check_invariants(void);
+#endif
 static void gtt_sessions_add(Oid relid, Oid table_relid);
 static void gtt_sessions_remove(Oid relid);
 static ProcNumber gtt_first_other_session_with_storage(Oid relid);
@@ -449,28 +492,35 @@ GttInitSessionStorage(Relation relation)
 	 * starts out equal to the catalog relfilenode, but a transactional
 	 * TRUNCATE swaps in a new session-local relfilenumber via
 	 * GttSetNewSessionRelfilenumber without touching the shared catalog, so
-	 * the two may legitimately diverge.  CLUSTER, REINDEX, SET TABLESPACE,
-	 * SET LOGGED and heap rewrites (which would rotate the shared relfilenode
-	 * itself) remain blocked for GTTs.
+	 * the two may legitimately diverge -- though only once storage has been
+	 * materialized (an entry without storage was either just created or just
+	 * refreshed above, so it must match the catalog).  CLUSTER, REINDEX, SET
+	 * TABLESPACE, SET LOGGED and heap rewrites (which would rotate the shared
+	 * relfilenode itself) remain blocked for GTTs.
 	 */
-	Assert(RelFileNumberIsValid(entry->locator.relNumber));
+	Assert(entry->storage_created ||
+		   entry->locator.relNumber == relation->rd_rel->relfilenode);
 
 	/* Point the relation at our per-session storage */
 	relation->rd_locator = entry->locator;
 	relation->rd_backend = ProcNumberForTempRelations();
 
 	/*
-	 * No physical file is created here.  Reads of unmaterialized storage
-	 * complete without one (the zero-blocks short-circuits in
-	 * bufmgr.c/tableam.c report the relation empty), so the file is deferred
-	 * to GttEnsureSessionStorage at the first genuine data access.
+	 * Note: no physical file is created here.  Reads of unmaterialized
+	 * storage complete without one (the zero-blocks short-circuits in
+	 * bufmgr.c/tableam.c report the relation empty), so the file -- and the
+	 * sessions-registry entry that makes peer DDL respect our data -- are
+	 * deferred to GttEnsureSessionStorage at the first genuine data access.
 	 */
 }
 
 /*
  * gtt_init_entry
- *		Initialize a newly created per-session map entry from the relation's
- *		current catalog state.
+ *		(Re)initialize a per-session map entry from the relation's current
+ *		catalog state.
+ *
+ * Used for newly created entries and to refresh a stale resource-less
+ * entry whose OID has been recycled (see GttInitSessionStorage).
  */
 static void
 gtt_init_entry(GttStorageEntry *entry, Relation relation)
@@ -498,6 +548,7 @@ gtt_init_entry(GttStorageEntry *entry, Relation relation)
 	entry->locator.relNumber = relation->rd_rel->relfilenode;
 	entry->storage_created = false;
 	entry->is_index = (relation->rd_rel->relkind == RELKIND_INDEX);
+	entry->is_sequence = (relation->rd_rel->relkind == RELKIND_SEQUENCE);
 	if (entry->is_index && relation->rd_index != NULL)
 		entry->heap_relid = relation->rd_index->indrelid;
 	else
@@ -515,6 +566,9 @@ gtt_init_entry(GttStorageEntry *entry, Relation relation)
 	entry->relpages = 0;
 	entry->reltuples = 0;
 	entry->relallvisible = 0;
+	entry->oldest_xid = InvalidTransactionId;
+	entry->xid_warned = false;
+	entry->session_relminmxid = InvalidMultiXactId;
 	entry->on_commit_delete = false;
 	entry->toast_relid = InvalidOid;
 
@@ -528,11 +582,23 @@ gtt_init_entry(GttStorageEntry *entry, Relation relation)
 
 /*
  * GttEnsureSessionStorage
- *		Materialize this session's physical storage for a GTT.
+ *		Materialize this session's storage for a GTT: create the per-session
+ *		file and register in the shared sessions registry.
  *
- * Called the first time a GTT is genuinely accessed for data: it creates the
- * per-session file (registered for delete-at-abort via RelationCreateStorage).
- * Reads never call this -- an unmaterialized GTT reads as empty.
+ * Called at the first genuine data access (heap inserts, index builds,
+ * sequence seeding), not at relation open: sessions that merely plan or
+ * read a never-written GTT hold no file and no registry entry, so they
+ * neither pay for storage nor block peer DDL.
+ *
+ * Registration happens here, with the file: "another session has live
+ * per-session data" (GttCheckDroppable/GttCheckAlterable) is then
+ * literally true.  We deliberately take no session-level lock on the GTT
+ * -- a session-lifetime AccessShareLock would make any AccessExclusiveLock
+ * acquisition (a peer's TRUNCATE of its own private data, or a DROP that
+ * the registry would reject with a clean error) block until this backend
+ * disconnects.  In-flight windows are covered by the ordinary
+ * transaction-level lock our caller holds while we're added to the
+ * registry.
  */
 void
 GttEnsureSessionStorage(Relation relation)
@@ -566,9 +632,38 @@ GttEnsureSessionStorage(Relation relation)
 				errcode(ERRCODE_INVALID_TRANSACTION_STATE),
 				errmsg("cannot initialize global temporary table storage during a parallel operation"));
 
-	RelationCreateStorage(entry->locator, RELPERSISTENCE_GLOBAL_TEMP, true);
+	/*
+	 * Create the file, then register so peer DDL sees our live data.
+	 *
+	 * For tables and indexes the file carries a delete-at-abort registration
+	 * and the materialization is recorded in storage_subid, making it fully
+	 * transactional.  Storage of a pre-existing sequence is deliberately NOT
+	 * transactional: sequence advancement must survive the abort of whichever
+	 * transaction happened to first materialize the sequence, or rolled-back
+	 * nextval calls would hand out the same values again -- regular and local
+	 * temporary sequences never replay values.  Such a file persists until
+	 * session end, DISCARD, or DROP, and its entry is made permanent for the
+	 * session (create_subid cleared) so the abort paths leave both alone.
+	 * Only when the sequence's own defining CREATE is still in flight
+	 * (rd_createSubid) is the storage transactional like everything else: if
+	 * that CREATE aborts, the catalog row vanishes and the file must go with
+	 * it.
+	 */
+	if (entry->is_sequence &&
+		relation->rd_createSubid == InvalidSubTransactionId)
+	{
+		RelationCreateStorage(entry->locator, RELPERSISTENCE_GLOBAL_TEMP,
+							  false);
+		entry->create_subid = InvalidSubTransactionId;
+	}
+	else
+	{
+		RelationCreateStorage(entry->locator, RELPERSISTENCE_GLOBAL_TEMP,
+							  true);
+		if (!entry->is_sequence)
+			entry->storage_subid = GetCurrentSubTransactionId();
+	}
 	entry->storage_created = true;
-	entry->storage_subid = GetCurrentSubTransactionId();
 	gtt_xact_state_dirty = true;
 
 	gtt_sessions_add(relid,
@@ -653,6 +748,9 @@ GttSetNewSessionRelfilenumber(Relation relation, RelFileNumber newrelfilenumber)
 	undo->prev_relpages = entry->relpages;
 	undo->prev_reltuples = entry->reltuples;
 	undo->prev_relallvisible = entry->relallvisible;
+	undo->prev_oldest_xid = entry->oldest_xid;
+	undo->prev_xid_warned = entry->xid_warned;
+	undo->prev_session_relminmxid = entry->session_relminmxid;
 	gtt_swap_undo = lcons(undo, gtt_swap_undo);
 	MemoryContextSwitchTo(oldcxt);
 
@@ -661,7 +759,8 @@ GttSetNewSessionRelfilenumber(Relation relation, RelFileNumber newrelfilenumber)
 
 	/*
 	 * The new file is empty: indexes must be lazily rebuilt on next access
-	 * (GttBuildIndexIfNeeded), and previous ANALYZE results no longer apply.
+	 * (GttBuildIndexIfNeeded), previous ANALYZE results no longer apply, and
+	 * there is no unfrozen data left to track for wraparound purposes.
 	 * Column-level statistics are left alone, matching the behavior of
 	 * TRUNCATE on regular tables, which does not clear pg_statistic.
 	 */
@@ -683,6 +782,9 @@ GttSetNewSessionRelfilenumber(Relation relation, RelFileNumber newrelfilenumber)
 	entry->relpages = 0;
 	entry->reltuples = 0;
 	entry->relallvisible = 0;
+	entry->oldest_xid = InvalidTransactionId;
+	entry->xid_warned = false;
+	entry->session_relminmxid = InvalidMultiXactId;
 
 	/* Point the open relcache entry at the new storage. */
 	relation->rd_locator = entry->locator;
@@ -714,6 +816,9 @@ gtt_swap_undo_apply(GttSwapUndo *undo)
 	entry->relpages = undo->prev_relpages;
 	entry->reltuples = undo->prev_reltuples;
 	entry->relallvisible = undo->prev_relallvisible;
+	entry->oldest_xid = undo->prev_oldest_xid;
+	entry->xid_warned = undo->prev_xid_warned;
+	entry->session_relminmxid = undo->prev_session_relminmxid;
 
 	/*
 	 * Refresh the relcache entry so rd_locator points back at the surviving
@@ -724,19 +829,28 @@ gtt_swap_undo_apply(GttSwapUndo *undo)
 
 /*
  * GttHasSessionStorage
- *		Check if the current session has initialized storage for a GTT.
+ *		Check if the current session has materialized storage for a GTT.
+ *
+ * True only once a per-session file actually exists.  Sessions that have
+ * merely opened a GTT (planning, EXPLAIN, reading a never-written table)
+ * hold a backend-local map entry but no file; for them this returns false.
  *
  * Used by pg_relation_filepath to decide whether to surface the current
- * session's private file path for a GTT (or NULL when this session has
- * not yet accessed it).
+ * session's private file path, and by the zero-blocks short-circuits in
+ * bufmgr.c/tableam.c that let reads of unmaterialized storage complete
+ * without any file.
  */
 bool
 GttHasSessionStorage(Oid relid)
 {
+	GttStorageEntry *entry;
+
 	if (gtt_storage_hash == NULL)
 		return false;
 
-	return hash_search(gtt_storage_hash, &relid, HASH_FIND, NULL) != NULL;
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+											HASH_FIND, NULL);
+	return entry != NULL && entry->storage_created;
 }
 
 /*
@@ -767,6 +881,238 @@ GttSessionIndexUsable(Oid relid)
 	return entry != NULL && entry->storage_created && entry->index_built;
 }
 
+/*
+ * GttPrepareAccessGuts
+ *		Materialize storage for writes, and fail-closed guard against accessing global temporary table data whose
+ *		oldest unfrozen xmin has aged toward the transaction-ID wraparound /
+ *		CLOG-truncation horizon.
+ *
+ * A GTT contributes nothing to datfrozenxid (its shared pg_class row carries
+ * invalid relfrozenxid/relminmxid), so CLOG can be truncated, and XID
+ * assignment can advance, past a still-live GTT tuple's xmin unless this
+ * session freezes its own data.  A long-lived session that retains data in an
+ * ON COMMIT PRESERVE ROWS GTT can therefore reach a point where reading that
+ * data either fails with a "could not access status of transaction" error or
+ * is silently mis-judged once the xmin is ~2^31 in the past.  To prevent wrong
+ * results, refuse access once the relation's oldest tracked xmin reaches the
+ * cluster CLOG-truncation horizon (TransamVariables->oldestXid), and warn as
+ * it approaches.
+ *
+ * The remedy is to VACUUM the table: that freezes this session's storage in
+ * place and advances oldest_xid (the session relfrozenxid) past the horizon,
+ * after which the guard stays quiet.  VACUUM does not go through the guarded
+ * entry points, so it remains available even once reads here have started to
+ * fail -- though if a tuple's commit status was never hinted and its CLOG is
+ * already gone, even freezing cannot read it, and only TRUNCATE / reconnect
+ * recovers.  This is why we warn well ahead of the horizon: VACUUM during the
+ * warning window is the reliable escape.
+ *
+ * Called from the heap read and write entry points (see heapam.c / indexam.c)
+ * whenever the relation is a global temporary table.  For inserts, records the
+ * current XID as the relation's oldest tracked xmin the first time data is
+ * added to empty storage.  Only heap entries are tracked; indexes are checked
+ * via their heap relation at scan begin.
+ */
+void
+GttPrepareAccessGuts(Relation rel, bool is_insert)
+{
+	GttStorageEntry *entry;
+	Oid			relid = RelationGetRelid(rel);
+	TransactionId frontier;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+											HASH_FIND, NULL);
+	if (entry == NULL)
+		return;
+
+	/* the file the mapping promises must actually exist */
+	Assert(!entry->storage_created ||
+		   smgrexists(smgropen(entry->locator, ProcNumberForTempRelations()),
+					  MAIN_FORKNUM));
+
+	/*
+	 * Writes are the moment per-session storage springs into existence: reads
+	 * of unmaterialized storage complete without a file via the zero-blocks
+	 * short-circuits, but an insert is about to extend the relation and needs
+	 * the file (and the registry entry that makes peer DDL respect our
+	 * now-live data).
+	 */
+	if (is_insert && !entry->storage_created)
+		GttEnsureSessionStorage(rel);
+
+	/* Record the first write into otherwise-empty storage. */
+	if (is_insert && !TransactionIdIsValid(entry->oldest_xid))
+	{
+		/*
+		 * Seed with the top-level XID, not GetCurrentTransactionId(): inside
+		 * a subtransaction the latter returns the subxact's XID, which is
+		 * always newer than the parent's.  If the first write into empty
+		 * storage happened under a savepoint, a later write at an outer
+		 * nesting level would stamp tuples with an older xmin than the seed,
+		 * and VACUUM (which uses oldest_xid as the relfrozenxid cutoff) would
+		 * then see "xmin from before relfrozenxid".  The top-level XID is
+		 * assigned before any of its children, so it is a valid lower bound
+		 * for every xmin this transaction can write.
+		 */
+		entry->oldest_xid = GetTopTransactionId();
+		entry->xid_warned = false;
+
+		/*
+		 * Seed the session relminmxid too.  No multixact older than the
+		 * current next-multixact can appear in data written from here on, so
+		 * this is a valid lower bound; a later VACUUM may advance it.
+		 */
+		entry->session_relminmxid = ReadNextMultiXactId();
+		return;					/* freshly written data is current */
+	}
+
+	if (!TransactionIdIsValid(entry->oldest_xid))
+		return;					/* no data tracked for this relation */
+
+	/*
+	 * Read the cluster-wide CLOG-truncation horizon.  A lock-free read is
+	 * acceptable: oldestXid only ever advances, it changes only at CLOG
+	 * truncation (infrequently), and we re-check on every access.  A slightly
+	 * stale (older) value can only make us less aggressive, and the worst
+	 * case is then the ordinary "could not access status of transaction"
+	 * error rather than a wrong result.
+	 */
+	frontier = TransamVariables->oldestXid;
+	if (!TransactionIdIsValid(frontier))
+		return;
+
+	/*
+	 * Past the horizon: CLOG may be gone.  Refuse rather than risk
+	 * corruption.
+	 */
+	if (TransactionIdPrecedes(entry->oldest_xid, frontier))
+		ereport(ERROR,
+				errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED),
+				errmsg("global temporary table \"%s\" contains data older than the transaction-ID horizon",
+					   RelationGetRelationName(rel)),
+				errdetail("The oldest row was written by transaction %u, which precedes the cluster commit-log truncation horizon (%u).",
+						  entry->oldest_xid, frontier),
+				errhint("VACUUM the table to freeze its data in place; if that fails because the data is already past the commit-log horizon, TRUNCATE the table or reconnect."));
+
+	/*
+	 * Approaching the horizon: warn once per relation per session.
+	 *
+	 * We know oldest_xid is at or after the frontier (it did not precede it
+	 * above), so both the gap below the data and the cluster's total CLOG
+	 * history are forward distances under 2^31.  Warn when the data sits
+	 * within the configured head room of the frontier -- but only once the
+	 * CLOG history itself is longer than that head room.  Otherwise (a young
+	 * cluster, or one whose datfrozenxid is kept close to the next XID) the
+	 * head-room band would cover even freshly written data, producing a
+	 * warning that does not reflect any real risk.
+	 */
+	if (!entry->xid_warned && global_temp_xid_warn_margin > 0)
+	{
+		TransactionId next_xid = XidFromFullTransactionId(TransamVariables->nextXid);
+		uint32		clog_history = (uint32) (next_xid - frontier);
+		uint32		gap = (uint32) (entry->oldest_xid - frontier);
+		uint32		margin = (uint32) global_temp_xid_warn_margin;
+
+		if (clog_history > margin && gap <= margin)
+		{
+			ereport(WARNING,
+					errmsg("global temporary table \"%s\" contains data approaching the transaction-ID horizon",
+						   RelationGetRelationName(rel)),
+					errhint("VACUUM the table to freeze its data; otherwise it is lost if its oldest row reaches transaction-ID wraparound."));
+			entry->xid_warned = true;
+		}
+	}
+}
+
+/*
+ * GttGetSessionFrozenXids
+ *		Fetch the per-session freeze horizon for a global temporary table.
+ *
+ * VACUUM uses these in place of the shared pg_class relfrozenxid/relminmxid
+ * (which are always invalid for a GTT) as the starting cutoffs for the
+ * relation.  oldest_xid doubles as the session relfrozenxid; session_relminmxid
+ * is its multixact counterpart.  Returns false, leaving the outputs untouched,
+ * when this session has no tracked data for the relation -- there is then
+ * nothing to vacuum.
+ */
+bool
+GttGetSessionFrozenXids(Oid relid, TransactionId *relfrozenxid,
+						MultiXactId *relminmxid)
+{
+	GttStorageEntry *entry;
+
+	if (gtt_storage_hash == NULL)
+		return false;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+											HASH_FIND, NULL);
+	if (entry == NULL || !TransactionIdIsValid(entry->oldest_xid))
+		return false;
+
+	*relfrozenxid = entry->oldest_xid;
+	*relminmxid = entry->session_relminmxid;
+	return true;
+}
+
+/*
+ * GttUpdateSessionFrozenXids
+ *		Persist the freeze horizon left behind by a VACUUM of a global
+ *		temporary table.
+ *
+ * The shared pg_class row cannot hold per-session freeze state, so VACUUM
+ * stores its new relfrozenxid/relminmxid here instead.  The next VACUUM reads
+ * them back via GttGetSessionFrozenXids, and the wraparound guard
+ * (GttPrepareAccess) benefits immediately because oldest_xid is the same
+ * field.  Advancing the horizon clears the approaching-wraparound warning
+ * throttle.
+ *
+ * Invalid inputs (e.g. the index writeback, or a non-aggressive VACUUM that
+ * skipped all-visible pages) leave the stored values unchanged.  *_updated, if
+ * supplied, report whether the corresponding value advanced.
+ */
+void
+GttUpdateSessionFrozenXids(Oid relid, TransactionId relfrozenxid,
+						   MultiXactId relminmxid,
+						   bool *frozenxid_updated, bool *minmulti_updated)
+{
+	GttStorageEntry *entry;
+
+	if (frozenxid_updated)
+		*frozenxid_updated = false;
+	if (minmulti_updated)
+		*minmulti_updated = false;
+
+	if (gtt_storage_hash == NULL)
+		return;
+
+	entry = (GttStorageEntry *) hash_search(gtt_storage_hash, &relid,
+											HASH_FIND, NULL);
+	if (entry == NULL)
+		return;
+
+	if (TransactionIdIsNormal(relfrozenxid) &&
+		(!TransactionIdIsValid(entry->oldest_xid) ||
+		 TransactionIdPrecedes(entry->oldest_xid, relfrozenxid)))
+	{
+		entry->oldest_xid = relfrozenxid;
+		entry->xid_warned = false;
+		if (frozenxid_updated)
+			*frozenxid_updated = true;
+	}
+
+	if (MultiXactIdIsValid(relminmxid) &&
+		(!MultiXactIdIsValid(entry->session_relminmxid) ||
+		 MultiXactIdPrecedes(entry->session_relminmxid, relminmxid)))
+	{
+		entry->session_relminmxid = relminmxid;
+		if (minmulti_updated)
+			*minmulti_updated = true;
+	}
+}
+
 /*
  * gtt_remove_entry
  *		Release per-session state for a GTT and remove its hash entry.
@@ -866,6 +1212,10 @@ gtt_revert_storage(GttStorageEntry *entry)
 	 */
 	if (entry->is_index)
 		entry->build_deferred = true;
+
+	entry->oldest_xid = InvalidTransactionId;
+	entry->xid_warned = false;
+	entry->session_relminmxid = InvalidMultiXactId;
 }
 
 /*
@@ -893,6 +1243,132 @@ gtt_remove_relids(List *to_remove)
 	list_free(to_remove);
 }
 
+/*
+ * gtt_truncate_dependents
+ *		Physically empty the still-materialized indexes (and toast) of
+ *		heaps whose own storage was just reverted by an abort.
+ *
+ * When a heap's storage was created in the aborting (sub)transaction, its
+ * file is unlinked by PendingRelDelete -- but an index materialized in an
+ * EARLIER transaction (e.g. built empty by an index scan, or by CREATE
+ * INDEX on a then-populated heap) keeps its committed file, now full of
+ * entries whose TIDs point into the vanished heap file.  Those are not
+ * MVCC-dead references that visibility checks would hide; they dangle
+ * physically.  Truncate such indexes and clear index_built so the next
+ * access rebuilds them against the (now-empty) heap; the same pass covers
+ * toast relations via the heap's recorded toast_relid.
+ *
+ * Mirrors the heap_relids matching in PreCommit_gtt_on_commit.  Frees the
+ * list.
+ */
+static void
+gtt_truncate_dependents(List *heap_relids)
+{
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+
+	if (heap_relids == NIL)
+		return;
+
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->is_index)
+		{
+			if (OidIsValid(entry->heap_relid) &&
+				list_member_oid(heap_relids, entry->heap_relid) &&
+				entry->storage_created)
+			{
+				gtt_truncate_smgr(entry);
+				entry->index_built = false;
+			}
+		}
+		else if (list_member_oid(heap_relids, entry->relid))
+			gtt_truncate_smgr(entry);	/* toast heap; no-op if empty */
+	}
+	list_free(heap_relids);
+}
+
+#ifdef USE_ASSERT_CHECKING
+/*
+ * gtt_session_registered
+ *		Does the shared sessions registry hold this backend's entry for relid?
+ */
+static bool
+gtt_session_registered(Oid relid)
+{
+	GttSessionsKey key;
+	bool		found;
+
+	if (GttSessionsHash == NULL)
+		return false;
+
+	init_sessions_key(&key, relid);
+
+	LWLockAcquire(GttSessionsLock, LW_SHARED);
+	(void) hash_search(GttSessionsHash, &key, HASH_FIND, &found);
+	LWLockRelease(GttSessionsLock);
+
+	return found;
+}
+
+/*
+ * gtt_check_invariants
+ *		Cross-check the per-session storage map once a top-level transaction
+ *		has settled.
+ *
+ * The map's fields interact in ways that scattered updates can silently
+ * break (and have: every bug the randomized stress test found was a broken
+ * invariant here that only surfaced as a read error several statements
+ * later).  Catching the inconsistency at the transaction boundary that
+ * produced it points directly at the cause.  Filesystem-level agreement is
+ * asserted at the use sites instead (GttPrepareAccessGuts and
+ * gtt_build_index_internal), because this callback runs before
+ * smgrDoPendingDeletes and the files' fate is not yet settled here.
+ */
+static void
+gtt_check_invariants(void)
+{
+	HASH_SEQ_STATUS status;
+	GttStorageEntry *entry;
+
+	/* every swap was either committed or undone */
+	Assert(gtt_swap_undo == NIL);
+
+	hash_seq_init(&status, gtt_storage_hash);
+	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
+	{
+		/* a top-level transaction end settles all subxact bookkeeping */
+		Assert(entry->create_subid == InvalidSubTransactionId);
+		Assert(entry->storage_subid == InvalidSubTransactionId);
+		Assert(entry->index_subid == InvalidSubTransactionId);
+		Assert(entry->stats_subid == InvalidSubTransactionId);
+		Assert(entry->demat_subid == InvalidSubTransactionId);
+		Assert(entry->drop_subid == InvalidSubTransactionId);
+
+		/* the relkind flags are mutually exclusive ... */
+		Assert(!(entry->is_index && entry->is_sequence));
+		/* ... and only indexes carry build state */
+		Assert(!entry->index_built || entry->is_index);
+		Assert(!entry->build_deferred || entry->is_index);
+
+		/* no storage => no index structure; a built index is not pending */
+		Assert(!entry->index_built || entry->storage_created);
+		Assert(!entry->index_built || !entry->build_deferred);
+
+		/*
+		 * The shared sessions registry mirrors materialization exactly --
+		 * except during backend exit, where gtt_session_cleanup (a shmem-exit
+		 * callback) deregisters everything before AbortOutOfAnyTransaction
+		 * triggers this walker for a still-open transaction (pg_dump, for
+		 * one, disconnects without closing its read-only transaction).
+		 */
+		Assert(proc_exit_inprogress ||
+			   entry->storage_created == gtt_session_registered(entry->relid));
+	}
+}
+#endif
+
 /*
  * gtt_xact_callback
  *		Reconcile gtt_storage_hash with transaction completion.
@@ -914,6 +1390,7 @@ gtt_xact_callback(XactEvent event, void *arg)
 	List	   *to_remove = NIL;
 	List	   *to_invalidate = NIL;
 	List	   *to_deregister = NIL;
+	List	   *reverted_heaps = NIL;
 	ListCell   *lc;
 
 	if (gtt_storage_hash == NULL)
@@ -998,6 +1475,7 @@ gtt_xact_callback(XactEvent event, void *arg)
 						srel = smgropen(entry->locator,
 										ProcNumberForTempRelations());
 						if (!smgrexists(srel, MAIN_FORKNUM) ||
+							entry->is_sequence ||
 							smgrnblocks(srel, MAIN_FORKNUM) == 0)
 						{
 							smgrdounlinkall(&srel, 1, false);
@@ -1049,6 +1527,14 @@ gtt_xact_callback(XactEvent event, void *arg)
 					gtt_revert_storage(entry);
 					to_deregister = lappend_oid(to_deregister, entry->relid);
 					to_invalidate = lappend_oid(to_invalidate, entry->relid);
+					if (!entry->is_index)
+					{
+						reverted_heaps = lappend_oid(reverted_heaps,
+													 entry->relid);
+						if (OidIsValid(entry->toast_relid))
+							reverted_heaps = lappend_oid(reverted_heaps,
+														 entry->toast_relid);
+					}
 				}
 				if (entry->index_subid != InvalidSubTransactionId)
 				{
@@ -1081,6 +1567,7 @@ gtt_xact_callback(XactEvent event, void *arg)
 	}
 
 	gtt_remove_relids(to_remove);
+	gtt_truncate_dependents(reverted_heaps);
 
 	foreach_oid(relid, to_deregister)
 		gtt_sessions_remove(relid);
@@ -1090,6 +1577,10 @@ gtt_xact_callback(XactEvent event, void *arg)
 		RelationCacheInvalidateEntry(relid);
 	list_free(to_invalidate);
 
+#ifdef USE_ASSERT_CHECKING
+	gtt_check_invariants();
+#endif
+
 	/* every entry has now been settled */
 	gtt_xact_state_dirty = false;
 }
@@ -1114,6 +1605,7 @@ gtt_subxact_callback(SubXactEvent event,
 	List	   *to_remove = NIL;
 	List	   *to_invalidate = NIL;
 	List	   *to_deregister = NIL;
+	List	   *reverted_heaps = NIL;
 	ListCell   *lc;
 
 	if (gtt_storage_hash == NULL)
@@ -1180,6 +1672,14 @@ gtt_subxact_callback(SubXactEvent event,
 				gtt_revert_storage(entry);
 				to_deregister = lappend_oid(to_deregister, entry->relid);
 				to_invalidate = lappend_oid(to_invalidate, entry->relid);
+				if (!entry->is_index)
+				{
+					reverted_heaps = lappend_oid(reverted_heaps,
+												 entry->relid);
+					if (OidIsValid(entry->toast_relid))
+						reverted_heaps = lappend_oid(reverted_heaps,
+													 entry->toast_relid);
+				}
 			}
 			if (entry->index_subid == mySubid)
 			{
@@ -1201,6 +1701,7 @@ gtt_subxact_callback(SubXactEvent event,
 	}
 
 	gtt_remove_relids(to_remove);
+	gtt_truncate_dependents(reverted_heaps);
 
 	foreach_oid(relid, to_deregister)
 		gtt_sessions_remove(relid);
@@ -1241,6 +1742,11 @@ gtt_truncate_smgr(GttStorageEntry *entry)
 	if (!entry->storage_created)
 		return;
 
+	/* Storage is being emptied; the xid horizon tracking restarts. */
+	entry->oldest_xid = InvalidTransactionId;
+	entry->xid_warned = false;
+	entry->session_relminmxid = InvalidMultiXactId;
+
 	reln = smgropen(entry->locator, ProcNumberForTempRelations());
 
 	/* tolerate an already-vanished file (defense in depth) */
@@ -1967,7 +2473,9 @@ GttResetAllSessionData(void)
 	 * transactional: an abort restores the data through the swap undo, and
 	 * the schedule is simply cancelled.  At commit, an entry whose main fork
 	 * is no longer empty (the same transaction wrote into it after the
-	 * DISCARD) is kept materialized instead.
+	 * DISCARD) is kept materialized instead; sequences are always released,
+	 * since rematerialization reseeds them to their start value, which is
+	 * what DISCARD's reset means anyway.
 	 */
 	hash_seq_init(&status, gtt_storage_hash);
 	while ((entry = (GttStorageEntry *) hash_seq_search(&status)) != NULL)
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index ab0b1c493f7..b79f5270ece 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -38,6 +38,7 @@
 #include "catalog/pg_class.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_inherits.h"
+#include "catalog/storage_gtt.h"
 #include "commands/async.h"
 #include "commands/defrem.h"
 #include "commands/progress.h"
@@ -1129,30 +1130,35 @@ vacuum_get_cutoffs(Relation rel, const VacuumParams *params,
 				safeOldestMxact,
 				aggressiveMXIDCutoff;
 
-	/*
-	 * Global temporary tables have session-local storage and carry invalid
-	 * relfrozenxid/relminmxid in their shared pg_class row, so they must
-	 * never reach the freeze machinery.  Every caller is expected to skip
-	 * them well before this point (see vacuum_rel() and cluster_rel());
-	 * arriving here with one means a skip was missed.  Computing cutoffs
-	 * would either trip the downstream
-	 * TransactionIdIsNormal()/MultiXactIdIsValid() assertions or silently
-	 * freeze session-local data against bogus limits, so error out loudly
-	 * instead.
-	 */
-	if (RelationIsGlobalTemp(rel))
-		elog(ERROR, "cannot compute freeze cutoffs for global temporary table \"%s\"",
-			 RelationGetRelationName(rel));
-
 	/* Use mutable copies of freeze age parameters */
 	freeze_min_age = params->freeze_min_age;
 	multixact_freeze_min_age = params->multixact_freeze_min_age;
 	freeze_table_age = params->freeze_table_age;
 	multixact_freeze_table_age = params->multixact_freeze_table_age;
 
-	/* Set pg_class fields in cutoffs */
-	cutoffs->relfrozenxid = rel->rd_rel->relfrozenxid;
-	cutoffs->relminmxid = rel->rd_rel->relminmxid;
+	/*
+	 * Set the starting relfrozenxid/relminmxid cutoffs for the relation.
+	 *
+	 * A global temporary table carries invalid values in its shared pg_class
+	 * row -- they are common to all sessions, but the data is per session --
+	 * so we take the cutoffs from this session's freeze horizon instead.  The
+	 * new horizon this VACUUM computes is written back there, not to pg_class
+	 * (see vac_update_relstats).  vacuum_rel() ensures a GTT with no session
+	 * data never reaches here.
+	 */
+	if (RelationIsGlobalTemp(rel))
+	{
+		if (!GttGetSessionFrozenXids(RelationGetRelid(rel),
+									 &cutoffs->relfrozenxid,
+									 &cutoffs->relminmxid))
+			elog(ERROR, "no session freeze horizon for global temporary table \"%s\"",
+				 RelationGetRelationName(rel));
+	}
+	else
+	{
+		cutoffs->relfrozenxid = rel->rd_rel->relfrozenxid;
+		cutoffs->relminmxid = rel->rd_rel->relminmxid;
+	}
 
 	/*
 	 * Acquire OldestXmin.
@@ -1476,6 +1482,21 @@ vac_update_relstats(Relation relation,
 	TransactionId oldfrozenxid;
 	MultiXactId oldminmulti;
 
+	/*
+	 * Global temporary tables keep their whole-relation statistics and freeze
+	 * horizon per session; the shared pg_class row is meaningless across
+	 * sessions and must never be written here.  Persist the freeze cutoffs
+	 * this VACUUM produced into the per-session storage instead. (Per-session
+	 * relpages/reltuples are maintained separately by ANALYZE, which never
+	 * routes through this function for a GTT.)
+	 */
+	if (RelationIsGlobalTemp(relation))
+	{
+		GttUpdateSessionFrozenXids(relid, frozenxid, minmulti,
+								   frozenxid_updated, minmulti_updated);
+		return;
+	}
+
 	rd = table_open(RelationRelationId, RowExclusiveLock);
 
 	/* Fetch a copy of the tuple to scribble on */
@@ -2182,26 +2203,71 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	}
 
 	/*
-	 * Skip the vacuum portion for global temporary tables.  GTT data lives in
-	 * per-session local buffers with no shared freeze state, so the vacuum
-	 * machinery (which assumes valid relfrozenxid/relminmxid) cannot safely
-	 * process them.
+	 * A global temporary table's data lives in per-session local storage that
+	 * behaves like a regular temp table's, so we can freeze it in place. What
+	 * differs is the bookkeeping: a GTT's shared pg_class row carries invalid
+	 * relfrozenxid/relminmxid (it is common to all sessions), so the starting
+	 * cutoffs come from, and the new horizon is written back to, this
+	 * session's per-session state instead (see vacuum_get_cutoffs /
+	 * vac_update_relstats).
 	 *
-	 * If ANALYZE was requested in the same command (VACUUM (ANALYZE)), we
-	 * return true so the caller still runs analyze_rel on this relation.
-	 * Otherwise we return false to short-circuit completely.
+	 * If this session has never written data to the table there is nothing to
+	 * vacuum; skip the vacuum portion.  When ANALYZE was also requested
+	 * (VACUUM (ANALYZE)) we still return true so the caller runs analyze_rel.
 	 */
 	if (RelationIsGlobalTemp(rel))
 	{
-		bool		can_analyze = (params.options & VACOPT_ANALYZE) != 0;
-
-		ereport(can_analyze ? DEBUG1 : INFO,
-				errmsg("skipping vacuum of \"%s\" --- data is session-local for a global temporary table",
-					   RelationGetRelationName(rel)));
-		relation_close(rel, lmode);
-		PopActiveSnapshot();
-		CommitTransactionCommand();
-		return can_analyze;
+		TransactionId session_relfrozenxid;
+		MultiXactId session_relminmxid;
+
+		/*
+		 * VACUUM FULL reassigns the shared relfilenode, which would
+		 * desynchronize every session's per-session storage, so it is
+		 * unsupported for GTTs.  Reject it here -- regardless of whether this
+		 * session holds data -- rather than letting it reach the repack path,
+		 * both so the outcome doesn't depend on the current session's data
+		 * and so the message can make clear that plain VACUUM is the
+		 * supported way to maintain a GTT.
+		 */
+		if (params.options & VACOPT_FULL)
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("cannot execute VACUUM FULL on global temporary tables"),
+					errhint("Plain VACUUM freezes a global temporary table's session-local data in place."));
+
+		/*
+		 * If this session has never written data to the table there is
+		 * nothing to vacuum; skip the vacuum portion.  When ANALYZE was also
+		 * requested (VACUUM (ANALYZE)) we still return true so the caller
+		 * runs analyze_rel.
+		 */
+		if (!GttGetSessionFrozenXids(RelationGetRelid(rel),
+									 &session_relfrozenxid,
+									 &session_relminmxid))
+		{
+			bool		can_analyze = (params.options & VACOPT_ANALYZE) != 0;
+			int			elevel;
+
+			/*
+			 * Tell the user we are skipping the relation they named -- but
+			 * not for a toast table reached via its parent: vacuuming a GTT
+			 * that simply has no toasted values would otherwise emit a
+			 * confusing message about an internal pg_toast relation.
+			 */
+			if (can_analyze || IsToastRelation(rel))
+				elevel = DEBUG1;
+			else
+				elevel = INFO;
+
+			ereport(elevel,
+					errmsg("skipping vacuum of \"%s\" --- this session has no data for the global temporary table",
+						   RelationGetRelationName(rel)));
+			relation_close(rel, lmode);
+			PopActiveSnapshot();
+			CommitTransactionCommand();
+			return can_analyze;
+		}
+		/* otherwise fall through and vacuum this session's storage */
 	}
 
 	/*
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index afaa058b046..fca49aaaea1 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -1205,6 +1205,15 @@
   max => 'MAX_KILOBYTES',
 },
 
+{ name => 'global_temp_xid_warn_margin', type => 'int', context => 'PGC_USERSET', group => 'CLIENT_CONN_STATEMENT',
+  short_desc => 'Transaction-ID head room before warning that global temporary table data is aging toward wraparound.',
+  long_desc => 'A warning is issued when the oldest unfrozen row in a global temporary table accessed by this session comes within this many transactions of the cluster commit-log truncation horizon. A hard error is always raised at the horizon itself, independently of this setting.',
+  variable => 'global_temp_xid_warn_margin',
+  boot_val => '100000000',
+  min => '0',
+  max => '2000000000',
+},
+
 { name => 'gss_accept_delegation', type => 'bool', context => 'PGC_SIGHUP', group => 'CONN_AUTH_AUTH',
   short_desc => 'Sets whether GSSAPI delegation should be accepted from the client.',
   variable => 'pg_gss_accept_delegation',
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 290ccbc543e..35215cdba9c 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -41,6 +41,7 @@
 #include "archive/archive_module.h"
 #include "catalog/namespace.h"
 #include "catalog/storage.h"
+#include "catalog/storage_gtt.h"
 #include "commands/async.h"
 #include "commands/extension.h"
 #include "commands/event_trigger.h"
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index ac38cddaaf9..f10171f2a2d 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -818,6 +818,10 @@
 #gin_pending_list_limit = 4MB
 #createrole_self_grant = ''             # set and/or inherit
 #event_triggers = on
+#global_temp_xid_warn_margin = 100000000        # transactions of head room before
+                                        # warning that global temporary table
+                                        # data is aging toward wraparound;
+                                        # 0 disables the warning
 
 # - Locale and Formatting -
 
diff --git a/src/include/catalog/storage_gtt.h b/src/include/catalog/storage_gtt.h
index d435ef49399..aba5b93af14 100644
--- a/src/include/catalog/storage_gtt.h
+++ b/src/include/catalog/storage_gtt.h
@@ -16,6 +16,13 @@
 #include "storage/shmem.h"
 #include "utils/rel.h"
 
+/*
+ * GUC: warn this many transactions before a GTT's oldest unfrozen xmin would
+ * reach the cluster CLOG-truncation horizon.  The hard error is fixed at the
+ * horizon itself; see GttPrepareAccess().
+ */
+extern PGDLLIMPORT int global_temp_xid_warn_margin;
+
 extern void GttInitSessionStorage(Relation relation);
 extern void GttEnsureSessionStorage(Relation relation);
 extern void GttSetNewSessionRelfilenumber(Relation relation,
@@ -23,6 +30,30 @@ extern void GttSetNewSessionRelfilenumber(Relation relation,
 extern bool GttHasSessionStorage(Oid relid);
 extern bool GttSessionIndexUsable(Oid relid);
 extern void GttScheduleDropSessionStorage(Oid relid);
+extern void GttPrepareAccessGuts(Relation rel, bool is_insert);
+
+/*
+ * GttPrepareAccess
+ *		Prepare a global temporary table for heap access.
+ *
+ * For writes, materializes the per-session storage if this is the first
+ * genuine data access; for all access, guards against the transaction-ID
+ * wraparound horizon.  Inline wrapper so the heap and index entry points
+ * can call this unconditionally: for anything but a global temporary
+ * table it costs one predictable branch.
+ */
+static inline void
+GttPrepareAccess(Relation rel, bool is_insert)
+{
+	if (RelationIsGlobalTemp(rel))
+		GttPrepareAccessGuts(rel, is_insert);
+}
+extern bool GttGetSessionFrozenXids(Oid relid, TransactionId *relfrozenxid,
+									MultiXactId *relminmxid);
+extern void GttUpdateSessionFrozenXids(Oid relid, TransactionId relfrozenxid,
+									   MultiXactId relminmxid,
+									   bool *frozenxid_updated,
+									   bool *minmulti_updated);
 extern void GttBuildIndexIfNeeded(Relation indexRelation);
 extern void GttMarkIndexBuildDeferred(Relation indexRelation);
 extern void GttPrepareIndexAccess(Relation indexRelation);
-- 
2.43.0



  [text/x-patch] 0010-Global-temporary-tables-pg_dump-psql-and-replication.patch (16.3K, ../[email protected]/11-0010-Global-temporary-tables-pg_dump-psql-and-replication.patch)
  download | inline diff:
From 823eaee770c9a394a6d3f53e98040a67e73b9461 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Fri, 13 Mar 2026 13:23:00 -0400
Subject: [PATCH 10/12] Global temporary tables: pg_dump, psql, and replication
 support

pg_dump:
- Never dump data for GTTs (data is per-session and ephemeral).
- Emit CREATE GLOBAL TEMPORARY TABLE with proper ON COMMIT clause
  (DELETE ROWS or PRESERVE ROWS) instead of leaking the internal
  on_commit_delete reloption into the WITH(...) clause.
- Extract on_commit_delete from reloptions in the catalog query,
  following the same array_remove pattern used for check_option.
- Emit CREATE GLOBAL TEMPORARY SEQUENCE for sequences with
  RELPERSISTENCE_GLOBAL_TEMP, mirroring the UNLOGGED prefix path.

psql:
- Show "Global temporary table" and "Global temporary index" labels
  in \d output, matching the existing "Unlogged table" pattern.
- The \dt+ verbose listing already showed "global temporary" in the
  Persistence column.

Replication:
- FOR ALL TABLES publications already exclude GTTs because
  is_publishable_class() requires RELPERSISTENCE_PERMANENT.
- Explicit CREATE PUBLICATION FOR TABLE and ALTER PUBLICATION ADD
  TABLE now reject GTTs in check_publication_add_relation, matching
  the existing treatment of TEMP and UNLOGGED relations.
- Physical replication only sees the catalog definition since GTT
  data uses local buffers and generates no WAL.

- pg_dump skips relation/attribute statistics for GTTs (their
  statistics are per-session; the shared pg_class/pg_statistic rows
  are never populated, and pg_restore_relation_stats() refuses to
  write shared statistics for them on restore).
- pg_upgrade excludes GTTs from the file transfer map: they have no
  files at their catalog locators, and their definitions travel with
  the pg_dump schema restore.  Per-session storage is recreated
  lazily in the new cluster.
---
 src/backend/catalog/pg_publication.c | 10 ++++-
 src/bin/pg_dump/pg_dump.c            | 67 +++++++++++++++++++++++-----
 src/bin/pg_dump/pg_dump.h            |  1 +
 src/bin/pg_upgrade/info.c            |  2 +
 src/bin/psql/describe.c              |  6 +++
 src/bin/psql/tab-complete.in.c       | 28 +++++++++---
 6 files changed, 97 insertions(+), 17 deletions(-)

diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 5c457d9aca8..9717267273a 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -92,7 +92,10 @@ check_publication_add_relation(PublicationRelInfo *pri)
 				 errmsg(errormsg, relname),
 				 errdetail("This operation is not supported for system tables.")));
 
-	/* UNLOGGED and TEMP relations cannot be part of publication. */
+	/*
+	 * UNLOGGED, TEMP, and GLOBAL TEMP relations cannot be part of
+	 * publication.
+	 */
 	if (targetrel->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
@@ -103,6 +106,11 @@ check_publication_add_relation(PublicationRelInfo *pri)
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
 				 errmsg(errormsg, relname),
 				 errdetail("This operation is not supported for unlogged tables.")));
+	else if (RelationIsGlobalTemp(targetrel))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg(errormsg, RelationGetRelationName(targetrel)),
+				errdetail("This operation is not supported for global temporary tables."));
 }
 
 /*
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 0c42d81a0be..d97352e1306 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -3050,6 +3050,10 @@ makeTableDataInfo(DumpOptions *dopt, TableInfo *tbinfo)
 	if (tbinfo->relkind == RELKIND_PARTITIONED_TABLE)
 		return;
 
+	/* Never dump data for global temporary tables (data is per-session) */
+	if (tbinfo->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		return;
+
 	/* Don't dump data in unlogged tables, if so requested */
 	if (tbinfo->relpersistence == RELPERSISTENCE_UNLOGGED &&
 		dopt->no_unlogged_table_data)
@@ -7315,6 +7319,7 @@ getTables(Archive *fout, int *numTables)
 	int			i_toastminmxid;
 	int			i_reloptions;
 	int			i_checkoption;
+	int			i_gtt_on_commit_delete;
 	int			i_toastreloptions;
 	int			i_reloftype;
 	int			i_foreignserver;
@@ -7409,12 +7414,24 @@ getTables(Archive *fout, int *numTables)
 
 	if (fout->remoteVersion >= 90300)
 		appendPQExpBufferStr(query,
-							 "array_remove(array_remove(c.reloptions,'check_option=local'),'check_option=cascaded') AS reloptions, "
+
+		/*
+		 * Filter out reloptions that are internal to the server and shouldn't
+		 * be emitted as user-visible WITH(...) items.  Keyed by prefix so
+		 * that a future change to how these options are serialised (different
+		 * value spellings, added keys) doesn't silently break pg_dump.
+		 */
+							 "ARRAY(SELECT r FROM unnest(c.reloptions) AS r "
+							 "WHERE r NOT LIKE 'check_option=%' "
+							 "  AND r NOT LIKE 'on_commit_delete=%') "
+							 "AS reloptions, "
 							 "CASE WHEN 'check_option=local' = ANY (c.reloptions) THEN 'LOCAL'::text "
-							 "WHEN 'check_option=cascaded' = ANY (c.reloptions) THEN 'CASCADED'::text ELSE NULL END AS checkoption, ");
+							 "WHEN 'check_option=cascaded' = ANY (c.reloptions) THEN 'CASCADED'::text ELSE NULL END AS checkoption, "
+							 "('on_commit_delete=true' = ANY (c.reloptions)) AS gtt_on_commit_delete, ");
 	else
 		appendPQExpBufferStr(query,
-							 "c.reloptions, NULL AS checkoption, ");
+							 "c.reloptions, NULL AS checkoption, "
+							 "false AS gtt_on_commit_delete, ");
 
 	if (fout->remoteVersion >= 90600)
 		appendPQExpBufferStr(query,
@@ -7540,6 +7557,7 @@ getTables(Archive *fout, int *numTables)
 	i_toastminmxid = PQfnumber(res, "tminmxid");
 	i_reloptions = PQfnumber(res, "reloptions");
 	i_checkoption = PQfnumber(res, "checkoption");
+	i_gtt_on_commit_delete = PQfnumber(res, "gtt_on_commit_delete");
 	i_toastreloptions = PQfnumber(res, "toast_reloptions");
 	i_reloftype = PQfnumber(res, "reloftype");
 	i_foreignserver = PQfnumber(res, "foreignserver");
@@ -7621,6 +7639,8 @@ getTables(Archive *fout, int *numTables)
 			tblinfo[i].checkoption = NULL;
 		else
 			tblinfo[i].checkoption = pg_strdup(PQgetvalue(res, i, i_checkoption));
+		tblinfo[i].gtt_on_commit_delete =
+			(strcmp(PQgetvalue(res, i, i_gtt_on_commit_delete), "t") == 0);
 		tblinfo[i].toast_reloptions = pg_strdup(PQgetvalue(res, i, i_toastreloptions));
 		tblinfo[i].reloftype = atooid(PQgetvalue(res, i, i_reloftype));
 		tblinfo[i].foreign_server = atooid(PQgetvalue(res, i, i_foreignserver));
@@ -7668,8 +7688,14 @@ getTables(Archive *fout, int *numTables)
 			tblinfo[i].dobj.components |= DUMP_COMPONENT_ACL;
 		tblinfo[i].hascolumnACLs = false;	/* may get set later */
 
-		/* Add statistics */
-		if (tblinfo[i].interesting)
+		/*
+		 * Add statistics.  Global temporary tables are skipped: their
+		 * statistics are per-session (the shared pg_class/pg_statistic rows
+		 * are never populated), and pg_restore_relation_stats() refuses to
+		 * write shared statistics for them on restore.
+		 */
+		if (tblinfo[i].interesting &&
+			tblinfo[i].relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
 		{
 			RelStatsInfo *stats;
 
@@ -8240,10 +8266,14 @@ getIndexes(Archive *fout, TableInfo tblinfo[], int numTables)
 					pg_fatal("could not parse %s array", "indattnames");
 			}
 
-			relstats = getRelationStatistics(fout, &indxinfo[j].dobj, relpages,
-											 PQgetvalue(res, j, i_reltuples),
-											 relallvisible, relallfrozen, indexkind,
-											 indAttNames, nindAttNames);
+			/* as in getTables, no statistics for global temp tables */
+			if (tbinfo->relpersistence != RELPERSISTENCE_GLOBAL_TEMP)
+				relstats = getRelationStatistics(fout, &indxinfo[j].dobj, relpages,
+												 PQgetvalue(res, j, i_reltuples),
+												 relallvisible, relallfrozen, indexkind,
+												 indAttNames, nindAttNames);
+			else
+				relstats = NULL;
 
 			contype = *(PQgetvalue(res, j, i_contype));
 			if (contype == 'p' || contype == 'u' || contype == 'x')
@@ -17712,6 +17742,15 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo)
 		if (ftoptions && ftoptions[0])
 			appendPQExpBuffer(q, "\nOPTIONS (\n    %s\n)", ftoptions);
 
+		/* Emit ON COMMIT clause for global temporary tables */
+		if (tbinfo->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+		{
+			if (tbinfo->gtt_on_commit_delete)
+				appendPQExpBufferStr(q, "\nON COMMIT DELETE ROWS");
+			else
+				appendPQExpBufferStr(q, "\nON COMMIT PRESERVE ROWS");
+		}
+
 		/*
 		 * For materialized views, create the AS clause just like a view. At
 		 * this point, we always mark the view as not populated.
@@ -19606,10 +19645,16 @@ dumpSequence(Archive *fout, const TableInfo *tbinfo)
 	}
 	else
 	{
+		const char *persistence_prefix = "";
+
+		if (tbinfo->relpersistence == RELPERSISTENCE_UNLOGGED)
+			persistence_prefix = "UNLOGGED ";
+		else if (tbinfo->relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+			persistence_prefix = "GLOBAL TEMPORARY ";
+
 		appendPQExpBuffer(query,
 						  "CREATE %sSEQUENCE %s\n",
-						  tbinfo->relpersistence == RELPERSISTENCE_UNLOGGED ?
-						  "UNLOGGED " : "",
+						  persistence_prefix,
 						  fmtQualifiedDumpable(tbinfo));
 
 		if (seq->seqtype != SEQTYPE_BIGINT)
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 5a6726d8b12..258a461c05f 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -315,6 +315,7 @@ typedef struct _tableInfo
 	char	   *reloptions;		/* options specified by WITH (...) */
 	char	   *checkoption;	/* WITH CHECK OPTION, if any */
 	char	   *toast_reloptions;	/* WITH options for the TOAST table */
+	bool		gtt_on_commit_delete;	/* GTT: ON COMMIT DELETE ROWS? */
 	bool		hasindex;		/* does it have any indexes? */
 	bool		hasrules;		/* does it have any rules? */
 	bool		hastriggers;	/* does it have any triggers? */
diff --git a/src/bin/pg_upgrade/info.c b/src/bin/pg_upgrade/info.c
index 37fff93892f..fa2bb736cee 100644
--- a/src/bin/pg_upgrade/info.c
+++ b/src/bin/pg_upgrade/info.c
@@ -503,6 +503,8 @@ get_rel_infos_query(void)
 					  "         ON c.relnamespace = n.oid "
 					  "  WHERE relkind IN (" CppAsString2(RELKIND_RELATION) ", "
 					  CppAsString2(RELKIND_MATVIEW) "%s) AND "
+	/* global temp tables have no persistent storage to transfer */
+					  "    c.relpersistence != " CppAsString2(RELPERSISTENCE_GLOBAL_TEMP) " AND "
 	/* exclude possible orphaned temp tables */
 					  "    ((n.nspname !~ '^pg_temp_' AND "
 					  "      n.nspname !~ '^pg_toast_temp_' AND "
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index a33b6a0bcab..a5b3b021d56 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -2138,6 +2138,9 @@ describeOneTableDetails(const char *schemaname,
 			if (tableinfo.relpersistence == RELPERSISTENCE_UNLOGGED)
 				printfPQExpBuffer(&title, _("Unlogged table \"%s.%s\""),
 								  schemaname, relationname);
+			else if (tableinfo.relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+				printfPQExpBuffer(&title, _("Global temporary table \"%s.%s\""),
+								  schemaname, relationname);
 			else
 				printfPQExpBuffer(&title, _("Table \"%s.%s\""),
 								  schemaname, relationname);
@@ -2154,6 +2157,9 @@ describeOneTableDetails(const char *schemaname,
 			if (tableinfo.relpersistence == RELPERSISTENCE_UNLOGGED)
 				printfPQExpBuffer(&title, _("Unlogged index \"%s.%s\""),
 								  schemaname, relationname);
+			else if (tableinfo.relpersistence == RELPERSISTENCE_GLOBAL_TEMP)
+				printfPQExpBuffer(&title, _("Global temporary index \"%s.%s\""),
+								  schemaname, relationname);
 			else
 				printfPQExpBuffer(&title, _("Index \"%s.%s\""),
 								  schemaname, relationname);
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index de547a8cb37..4db222bfeb2 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -1332,6 +1332,7 @@ static const pgsql_thing_t words_after_create[] = {
 	{"EXTENSION", Query_for_list_of_extensions},
 	{"FOREIGN DATA WRAPPER", NULL, NULL, NULL},
 	{"FOREIGN TABLE", NULL, NULL, NULL},
+	{"GLOBAL", NULL, NULL, NULL, NULL, THING_NO_DROP | THING_NO_ALTER},	/* for CREATE GLOBAL TEMP TABLE ... */
 	{"FUNCTION", NULL, NULL, Query_for_list_of_functions},
 	{"GROUP", Query_for_list_of_roles},
 	{"INDEX", NULL, NULL, &Query_for_list_of_indexes},
@@ -3861,6 +3862,12 @@ match_previous_words(int pattern_id,
 	/* Complete "CREATE TEMP/TEMPORARY" with the possible temp objects */
 	else if (TailMatches("CREATE", "TEMP|TEMPORARY"))
 		COMPLETE_WITH("SEQUENCE", "TABLE", "VIEW");
+	/* Complete "CREATE GLOBAL" with TEMP or TEMPORARY */
+	else if (TailMatches("CREATE", "GLOBAL"))
+		COMPLETE_WITH("TEMP", "TEMPORARY");
+	/* Complete "CREATE GLOBAL TEMP/TEMPORARY" with TABLE */
+	else if (TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY"))
+		COMPLETE_WITH("TABLE");
 	/* Complete "CREATE UNLOGGED" with TABLE or SEQUENCE */
 	else if (TailMatches("CREATE", "UNLOGGED"))
 		COMPLETE_WITH("TABLE", "SEQUENCE");
@@ -3875,17 +3882,21 @@ match_previous_words(int pattern_id,
 		COMPLETE_WITH("FOR VALUES", "DEFAULT");
 	/* Complete CREATE TABLE <name> with '(', AS, OF or PARTITION OF */
 	else if (TailMatches("CREATE", "TABLE", MatchAny) ||
-			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny))
+			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny) ||
+			 TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY", "TABLE", MatchAny))
 		COMPLETE_WITH("(", "AS", "OF", "PARTITION OF");
 	/* Complete CREATE TABLE <name> OF with list of composite types */
 	else if (TailMatches("CREATE", "TABLE", MatchAny, "OF") ||
-			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "OF"))
+			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "OF") ||
+			 TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY", "TABLE", MatchAny, "OF"))
 		COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_composite_datatypes);
 	/* Complete CREATE TABLE <name> [ (...) ] AS with list of keywords */
 	else if (TailMatches("CREATE", "TABLE", MatchAny, "AS") ||
 			 TailMatches("CREATE", "TABLE", MatchAny, "(*)", "AS") ||
 			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "AS") ||
-			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "(*)", "AS"))
+			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "(*)", "AS") ||
+			 TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY", "TABLE", MatchAny, "AS") ||
+			 TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY", "TABLE", MatchAny, "(*)", "AS"))
 		COMPLETE_WITH("EXECUTE", "SELECT", "TABLE", "VALUES", "WITH");
 	/* Complete CREATE TABLE name (...) with supported options */
 	else if (TailMatches("CREATE", "TABLE", MatchAny, "(*)"))
@@ -3895,17 +3906,24 @@ match_previous_words(int pattern_id,
 	else if (TailMatches("CREATE", "TEMP|TEMPORARY", "TABLE", MatchAny, "(*)"))
 		COMPLETE_WITH("AS", "INHERITS (", "ON COMMIT", "PARTITION BY", "USING",
 					  "TABLESPACE", "WITH (");
+	else if (TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY", "TABLE", MatchAny, "(*)"))
+		COMPLETE_WITH("AS", "INHERITS (", "ON COMMIT", "PARTITION BY", "USING",
+					  "TABLESPACE", "WITH (");
 	/* Complete CREATE TABLE (...) USING with table access methods */
 	else if (TailMatches("CREATE", "TABLE", MatchAny, "(*)", "USING") ||
-			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "(*)", "USING"))
+			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "(*)", "USING") ||
+			 TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY", "TABLE", MatchAny, "(*)", "USING"))
 		COMPLETE_WITH_QUERY(Query_for_list_of_table_access_methods);
 	/* Complete CREATE TABLE (...) WITH with storage parameters */
 	else if (TailMatches("CREATE", "TABLE", MatchAny, "(*)", "WITH", "(") ||
-			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "(*)", "WITH", "("))
+			 TailMatches("CREATE", "TEMP|TEMPORARY|UNLOGGED", "TABLE", MatchAny, "(*)", "WITH", "(") ||
+			 TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY", "TABLE", MatchAny, "(*)", "WITH", "("))
 		COMPLETE_WITH_LIST(table_storage_parameters);
 	/* Complete CREATE TABLE ON COMMIT with actions */
 	else if (TailMatches("CREATE", "TEMP|TEMPORARY", "TABLE", MatchAny, "(*)", "ON", "COMMIT"))
 		COMPLETE_WITH("DELETE ROWS", "DROP", "PRESERVE ROWS");
+	else if (TailMatches("CREATE", "GLOBAL", "TEMP|TEMPORARY", "TABLE", MatchAny, "(*)", "ON", "COMMIT"))
+		COMPLETE_WITH("DELETE ROWS", "DROP", "PRESERVE ROWS");
 
 /* CREATE TABLESPACE */
 	else if (Matches("CREATE", "TABLESPACE", MatchAny))
-- 
2.43.0



  [text/x-patch] 0011-Global-temporary-tables-regression-tests-and-documen.patch (175.0K, ../[email protected]/12-0011-Global-temporary-tables-regression-tests-and-documen.patch)
  download | inline diff:
From 5eeb481605abedb9a41ca0e57f83f0e117074bbf Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Sat, 14 Mar 2026 12:39:55 -0400
Subject: [PATCH 11/12] Global temporary tables: regression tests and
 documentation

Add regression tests covering:
- Basic CREATE/DROP and catalog verification
- INSERT, SELECT, UPDATE, DELETE, COPY operations
- Per-session data isolation via reconnect (data gone in new session)
- ON COMMIT PRESERVE ROWS (data survives commit)
- ON COMMIT DELETE ROWS (data truncated at commit, index rebuilt)
- ON COMMIT DROP rejected for GTTs
- DDL restrictions: ALTER SET LOGGED/UNLOGGED, FK constraints,
  inheritance mixing, temp schema rejection
- Operation restrictions: CLUSTER, REINDEX, VACUUM FULL rejection, CREATE
  INDEX CONCURRENTLY fallback to non-concurrent
- VACUUM: freezes session-local data in place, advancing the per-session
  freeze horizon and leaving the shared pg_class row untouched
- Row Level Security: policies correctly enforced on GTTs
- Dependent objects: views, triggers, SERIAL columns, DROP CASCADE
- Inheritance: GTT-to-GTT allowed, GTT-to-temp and GTT-to-perm blocked
- Toast table support with large values
- Subtransaction rollback behavior
- Index creation and usage
- TRUNCATE with and without indexes, insert-after-truncate
- ANALYZE: per-session stats stored locally, pg_class unchanged,
  stats reset after TRUNCATE, stats not visible in new sessions

Add isolation test verifying cross-session data isolation:
- Two sessions INSERT into the same GTT
- Each session sees only its own rows

Add a transaction-ID horizon TAP test exercising the wraparound guard on a
dedicated cluster: the approaching-horizon warning, the past-horizon access
refusal, recovery by VACUUM (freezing the data in place with no loss), and
recovery by TRUNCATE.

Update CREATE TABLE SGML documentation:
- New GLOBAL TEMPORARY parameter section describing shared
  definition / per-session data semantics, ON COMMIT behavior,
  restrictions, autovacuum, replication, and pg_dump handling
- Document per-session ANALYZE statistics behavior
- Document the transaction-ID horizon and VACUUM as the way to freeze
  session-local data (create_table.sgml and the
  global_temp_xid_warn_margin description in config.sgml)
- Update ON COMMIT section to mention global temporary tables
- Update SQL compatibility section to reflect that GLOBAL
  TEMPORARY now has standard-compliant semantics

The regression tests also cover transaction-safe TRUNCATE (plain,
savepoint, and RESTART IDENTITY rollback, index consistency, and
catalog-relfilenode stability), ALTER SEQUENCE RESTART on a GTT
sequence, the one-row sequence contract under a direct scan from a
fresh session, rejection of global temporary views, and TOAST
reclamation by ON COMMIT DELETE ROWS.

Also covered: rejection of materialized views over GTTs (direct and
via a view), transactional per-session ANALYZE stats, quiet VACUUM of
GTTs without toasted data, and DISCARD TEMP/ALL clearing per-session
GTT data (transactionally) and resetting GTT sequences.
---
 doc/src/sgml/config.sgml                      |   33 +
 doc/src/sgml/func/func-admin.sgml             |   27 +
 doc/src/sgml/ref/create_sequence.sgml         |   17 +-
 doc/src/sgml/ref/create_table.sgml            |  186 +-
 doc/src/sgml/ref/discard.sgml                 |    4 +-
 src/bin/pg_dump/t/002_pg_dump.pl              |   30 +-
 .../isolation/expected/global-temp-table.out  |  125 +
 src/test/isolation/isolation_schedule         |    1 +
 .../isolation/specs/global-temp-table.spec    |  148 +
 src/test/modules/test_misc/meson.build        |    1 +
 .../test_misc/t/014_gtt_xid_horizon.pl        |  132 +
 src/test/regress/expected/global_temp.out     | 2600 +++++++++++++++++
 src/test/regress/parallel_schedule            |    5 +
 src/test/regress/sql/global_temp.sql          | 1548 ++++++++++
 14 files changed, 4835 insertions(+), 22 deletions(-)
 create mode 100644 src/test/isolation/expected/global-temp-table.out
 create mode 100644 src/test/isolation/specs/global-temp-table.spec
 create mode 100644 src/test/modules/test_misc/t/014_gtt_xid_horizon.pl
 create mode 100644 src/test/regress/expected/global_temp.out
 create mode 100644 src/test/regress/sql/global_temp.sql

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index fa566c9e553..1a2d352055a 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -10909,6 +10909,39 @@ SET XML OPTION { DOCUMENT | CONTENT };
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-global-temp-xid-warn-margin" xreflabel="global_temp_xid_warn_margin">
+      <term><varname>global_temp_xid_warn_margin</varname> (<type>integer</type>)
+      <indexterm>
+       <primary><varname>global_temp_xid_warn_margin</varname></primary>
+       <secondary>configuration parameter</secondary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The data in a <link linkend="sql-createtable-global-temporary">global
+        temporary table</link> is session-local and is not frozen by the
+        autovacuum daemon, so its oldest rows can age toward the point to
+        which the commit log has been truncated; once a row predates that
+        point the owning session can no longer read it, and to avoid
+        returning wrong results an access to the table is refused with an
+        error.  Running <link linkend="sql-vacuum"><command>VACUUM</command></link>
+        on the table freezes the session's data in place and prevents this.
+        This parameter sets how many transactions of head room before that
+        horizon a <emphasis>warning</emphasis> is first issued, giving a
+        long-lived session advance notice to <command>VACUUM</command> the
+        table (or, failing that, <command>TRUNCATE</command> it or reconnect).
+        If this value is zero, no warning is issued; the hard error is
+        unaffected.  The default is 100 million transactions.
+       </para>
+       <para>
+        The warning is suppressed unless the cluster's commit-log history is
+        longer than this value, so it does not fire on freshly written data.
+        For more information on wraparound see
+        <xref linkend="vacuum-for-wraparound"/>.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-createrole-self-grant" xreflabel="createrole_self_grant">
       <term><varname>createrole_self_grant</varname> (<type>string</type>)
       <indexterm>
diff --git a/doc/src/sgml/func/func-admin.sgml b/doc/src/sgml/func/func-admin.sgml
index 0eae1c1f616..88dce74e011 100644
--- a/doc/src/sgml/func/func-admin.sgml
+++ b/doc/src/sgml/func/func-admin.sgml
@@ -2070,6 +2070,33 @@ SELECT pg_restore_relation_stats(
        </entry>
       </row>
 
+      <row>
+       <entry role="func_table_entry">
+        <para role="func_signature">
+         <indexterm>
+          <primary>pg_gtt_clear_stats</primary>
+         </indexterm>
+         <function>pg_gtt_clear_stats</function> ( <parameter>relid</parameter> <type>regclass</type> <literal>DEFAULT NULL</literal> )
+         <returnvalue>void</returnvalue>
+        </para>
+        <para>
+         Discards per-session relation- and column-level statistics for one
+         or all global temporary tables in the current session.  If
+         <parameter>relid</parameter> is not <literal>NULL</literal>,
+         only that table's statistics are cleared; otherwise statistics for
+         every global temporary table accessible to the session are cleared.
+         After clearing, the planner falls back to default estimates until
+         <command>ANALYZE</command> is run again in this session.
+        </para>
+        <para>
+         Unlike <function>pg_clear_relation_stats</function>, which modifies
+         shared statistics visible to all sessions, this function affects only
+         the calling session's private state, so
+         <literal>SELECT</literal> privilege on the table is sufficient.
+        </para>
+       </entry>
+      </row>
+
       <row>
        <entry role="func_table_entry"><para role="func_signature">
         <indexterm>
diff --git a/doc/src/sgml/ref/create_sequence.sgml b/doc/src/sgml/ref/create_sequence.sgml
index 0ffcd0febd1..e7e77bcd79a 100644
--- a/doc/src/sgml/ref/create_sequence.sgml
+++ b/doc/src/sgml/ref/create_sequence.sgml
@@ -21,7 +21,7 @@ PostgreSQL documentation
 
  <refsynopsisdiv>
 <synopsis>
-CREATE [ { TEMPORARY | TEMP } | UNLOGGED ] SEQUENCE [ IF NOT EXISTS ] <replaceable class="parameter">name</replaceable>
+CREATE [ [ GLOBAL | LOCAL ] { TEMPORARY | TEMP } | UNLOGGED ] SEQUENCE [ IF NOT EXISTS ] <replaceable class="parameter">name</replaceable>
     [ AS <replaceable class="parameter">data_type</replaceable> ]
     [ INCREMENT [ BY ] <replaceable class="parameter">increment</replaceable> ]
     [ MINVALUE <replaceable class="parameter">minvalue</replaceable> | NO MINVALUE ] [ MAXVALUE <replaceable class="parameter">maxvalue</replaceable> | NO MAXVALUE ]
@@ -94,6 +94,21 @@ SELECT * FROM <replaceable>name</replaceable>;
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term><literal>GLOBAL TEMPORARY</literal> or <literal>GLOBAL TEMP</literal></term>
+    <listitem>
+     <para>
+      If specified, the sequence is created as a global temporary
+      sequence.  The sequence definition is persistent across sessions,
+      but each session has its own independent counter that starts at
+      the <literal>START</literal> value on first use.  Values allocated
+      by <function>nextval</function> in one session are invisible to
+      other sessions.  See <xref linkend="sql-createtable-global-temporary"/>
+      for further discussion of global temporary objects.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term><literal>UNLOGGED</literal></term>
     <listitem>
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index e342585c7f0..1468aefc96c 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -214,11 +214,153 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
      </para>
 
      <para>
-      Optionally, <literal>GLOBAL</literal> or <literal>LOCAL</literal>
-      can be written before <literal>TEMPORARY</literal> or <literal>TEMP</literal>.
-      This presently makes no difference in <productname>PostgreSQL</productname>
-      and is deprecated; see
-      <xref linkend="sql-createtable-compatibility"/> below.
+      Optionally, <literal>LOCAL</literal> can be written before
+      <literal>TEMPORARY</literal> or <literal>TEMP</literal>.
+      This presently makes no difference in
+      <productname>PostgreSQL</productname>.
+      See <literal>GLOBAL TEMPORARY</literal> below for the effect
+      of the <literal>GLOBAL</literal> keyword.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry id="sql-createtable-global-temporary">
+    <term><literal>GLOBAL TEMPORARY</literal> or <literal>GLOBAL TEMP</literal></term>
+    <listitem>
+     <para>
+      If specified, the table is created as a global temporary table.
+      The table definition (schema) is persistent and visible to all
+      <glossterm linkend="glossary-session">sessions</glossterm>, but each
+      session gets its own private copy of the data.
+      Data inserted by one session is invisible to all other sessions.
+      Per-session data is automatically discarded when the session ends,
+      and can be discarded explicitly with <link linkend="sql-discard"><command>DISCARD TEMP</command></link>.
+     </para>
+
+     <para>
+      Unlike regular temporary tables, global temporary tables are created
+      in a permanent schema (not <literal>pg_temp</literal>) and are not
+      dropped at session end.  The table definition survives across
+      sessions and server restarts, similar to a permanent table.  Only
+      the data is per-session and ephemeral.
+     </para>
+
+     <para>
+      The <literal>ON COMMIT</literal> clause can be used to control
+      whether per-session data is preserved or deleted at the end of
+      each transaction.  <literal>ON COMMIT PRESERVE ROWS</literal>
+      is the default.  <literal>ON COMMIT DELETE ROWS</literal> causes
+      the per-session data to be truncated at each commit.
+      <literal>ON COMMIT DROP</literal> is not supported for global
+      temporary tables because the table definition is persistent.
+     </para>
+
+     <para>
+      Indexes created on a global temporary table also have shared
+      definitions but per-session data.  The index structure is
+      lazily initialized in each session on first access.  Unique
+      and primary key constraints enforce uniqueness within each
+      session's private data, not across sessions; two sessions may
+      independently hold rows with the same key value.
+     </para>
+
+     <para>
+      A sequence created implicitly for a <literal>SERIAL</literal>
+      or <literal>GENERATED AS IDENTITY</literal> column of a global
+      temporary table is itself a global temporary sequence.  Each
+      session therefore gets its own counter, independently starting
+      at the sequence's <literal>START</literal> value on first use.
+      The <literal>GLOBAL TEMPORARY</literal> clause of
+      <xref linkend="sql-createsequence"/> may also be used to create
+      a standalone global temporary sequence.
+     </para>
+
+     <para>
+      Global temporary tables have the following restrictions:
+      <itemizedlist>
+       <listitem>
+        <para>
+         Foreign key constraints on a global temporary table may only
+         reference other global temporary tables.
+        </para>
+       </listitem>
+       <listitem>
+        <para>
+         Global temporary tables cannot be mixed with regular temporary
+         tables in an inheritance hierarchy.
+        </para>
+       </listitem>
+       <listitem>
+        <para>
+         <command>ALTER TABLE SET LOGGED</command> and
+         <command>ALTER TABLE SET UNLOGGED</command> are not supported.
+        </para>
+       </listitem>
+       <listitem>
+        <para>
+         <command>DROP TABLE</command> and <command>ALTER TABLE</command>
+         are rejected with an error while any other session has written
+         data into the table (and not since discarded it by transaction
+         rollback, <command>TRUNCATE</command>, or session exit).
+         Sessions that have merely queried the table do not block these
+         commands.
+        </para>
+       </listitem>
+      </itemizedlist>
+     </para>
+
+     <para>
+      The <link linkend="autovacuum">autovacuum daemon</link> cannot
+      access global temporary table data because it is per-session.
+      <command>ANALYZE</command> can be run manually and will collect
+      per-session statistics used by the planner.  This includes both
+      relation-level statistics (page and tuple counts) for scan method
+      and join order decisions, and column-level statistics (histograms,
+      most-common values, distinct counts) for selectivity estimation.
+      Because each session's data may have a completely different
+      distribution, these statistics are stored in backend-local memory
+      rather than in the shared <structname>pg_statistic</structname>
+      catalog.  They are visible only within the session that ran
+      <command>ANALYZE</command> and do not survive reconnection.
+      Extended statistics (created via <command>CREATE
+      STATISTICS</command>) are not supported for global temporary
+      tables.  The <function>pg_gtt_relstats()</function>
+      and <function>pg_gtt_colstats()</function> functions
+      can be used to inspect per-session statistics for global
+      temporary tables, and <function>pg_gtt_clear_stats()</function>
+      discards them, returning the planner to default estimates
+      until the next <command>ANALYZE</command>.
+     </para>
+
+     <para>
+      Per-session data does not participate in the cluster-wide
+      transaction-ID wraparound accounting, so the autovacuum daemon never
+      freezes it.  A session that keeps rows in a global temporary table
+      (with <literal>ON COMMIT PRESERVE ROWS</literal>) across a very large
+      number of transactions will see its oldest rows age toward the point at
+      which the commit log is truncated.  Running
+      <link linkend="sql-vacuum"><command>VACUUM</command></link> on the
+      table freezes the session's own data in place and resets this aging,
+      so the data can be retained indefinitely; this is the recommended
+      maintenance for such long-lived sessions.
+      (<command>VACUUM FULL</command> is not supported on a global temporary
+      table, as it would reassign the table's shared on-disk relation.)
+      A warning is issued as the data approaches the horizon, controlled by
+      <xref linkend="guc-global-temp-xid-warn-margin"/>, to prompt a
+      <command>VACUUM</command> in time.  If data is nonetheless allowed to
+      age past the horizon before being frozen, an access to it is refused
+      with an error rather than risk a wrong result; at that point the data
+      can no longer be frozen, and the table must be
+      <command>TRUNCATE</command>d (or the session reconnected) to discard
+      it.  This is normally a concern only for sessions that stay connected
+      for days or weeks on a busy cluster.
+     </para>
+
+     <para>
+      Global temporary tables are not included in logical replication
+      publications, and their data is not written to the write-ahead
+      log.  <command>pg_dump</command> dumps the table definition but
+      never the data.
      </para>
     </listitem>
    </varlistentry>
@@ -1499,8 +1641,9 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
     <term><literal>ON COMMIT</literal></term>
     <listitem>
      <para>
-      The behavior of temporary tables at the end of a transaction
-      block can be controlled using <literal>ON COMMIT</literal>.
+      The behavior of temporary and global temporary tables at the end
+      of a transaction block can be controlled using
+      <literal>ON COMMIT</literal>.
       The three options are:
 
       <variablelist>
@@ -2435,21 +2578,26 @@ CREATE TABLE cities_partdef
    </para>
 
    <para>
-    The SQL standard also distinguishes between global and local temporary
-    tables, where a local temporary table has a separate set of contents for
-    each SQL module within each session, though its definition is still shared
-    across sessions.  Since <productname>PostgreSQL</productname> does not
-    support SQL modules, this distinction is not relevant in
-    <productname>PostgreSQL</productname>.
+    The SQL standard distinguishes between global and local temporary
+    tables.  <productname>PostgreSQL</productname> now supports
+    <literal>GLOBAL TEMPORARY</literal> tables: the table definition is
+    persistent and shared across sessions, while each session gets its
+    own private data.  This matches the SQL standard semantics.
+    <literal>LOCAL TEMPORARY</literal> (or just <literal>TEMPORARY</literal>)
+    creates a session-local table whose definition and data are both
+    dropped at session end, which is a <productname>PostgreSQL</productname>
+    extension.
    </para>
 
    <para>
-    For compatibility's sake, <productname>PostgreSQL</productname> will
-    accept the <literal>GLOBAL</literal> and <literal>LOCAL</literal> keywords
-    in a temporary table declaration, but they currently have no effect.
-    Use of these keywords is discouraged, since future versions of
-    <productname>PostgreSQL</productname> might adopt a more
-    standard-compliant interpretation of their meaning.
+    Note that this is a behavior change: in previous
+    <productname>PostgreSQL</productname> releases the
+    <literal>GLOBAL</literal> keyword was accepted for compatibility but
+    ignored, so <literal>CREATE GLOBAL TEMPORARY TABLE</literal> created
+    an ordinary session-local temporary table.  Applications relying on
+    that historical behavior must now spell it <literal>CREATE LOCAL
+    TEMPORARY TABLE</literal> (or simply <literal>CREATE TEMPORARY
+    TABLE</literal>).
    </para>
 
    <para>
diff --git a/doc/src/sgml/ref/discard.sgml b/doc/src/sgml/ref/discard.sgml
index bf44c523cac..6da8a25ee87 100644
--- a/doc/src/sgml/ref/discard.sgml
+++ b/doc/src/sgml/ref/discard.sgml
@@ -70,7 +70,9 @@ DISCARD { ALL | PLANS | SEQUENCES | TEMPORARY | TEMP }
     <term><literal>TEMPORARY</literal> or <literal>TEMP</literal></term>
     <listitem>
      <para>
-      Drops all temporary tables created in the current session.
+      Drops all temporary tables created in the current session, and
+      clears the current session's data in all global temporary tables
+      (resetting any global temporary sequences to their start values).
      </para>
     </listitem>
    </varlistentry>
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 9258948b583..46b2cb2a149 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -4988,7 +4988,35 @@ my %tests = (
 			no_table_access_method => 1,
 			only_dump_measurement => 1,
 		},
-	});
+	},
+
+	'CREATE GLOBAL TEMPORARY TABLE test_gtt' => {
+		create_order => 101,
+		create_sql => 'CREATE GLOBAL TEMPORARY TABLE public.test_gtt (
+						   id serial,
+						   val text
+					   );
+					   INSERT INTO public.test_gtt (val) VALUES (\'gtt_data\');',
+		regexp => qr/^
+			\QCREATE GLOBAL TEMPORARY TABLE public.test_gtt (\E\n
+			\s+\Qid integer NOT NULL,\E\n
+			\s+\Qval text\E\n
+			\Q)\E\n
+			\QON COMMIT PRESERVE ROWS;\E
+			/xm,
+		like =>
+		  { %full_runs, section_pre_data => 1, },
+	},
+
+	'CREATE GLOBAL TEMPORARY SEQUENCE test_gtt_id_seq' => {
+		regexp => qr/^
+			\QCREATE GLOBAL TEMPORARY SEQUENCE public.test_gtt_id_seq\E
+			/xm,
+		like =>
+		  { %full_runs, section_pre_data => 1, },
+	},
+
+);
 
 #########################################
 # Create a PG instance to test actually dumping from
diff --git a/src/test/isolation/expected/global-temp-table.out b/src/test/isolation/expected/global-temp-table.out
new file mode 100644
index 00000000000..bae0358eef9
--- /dev/null
+++ b/src/test/isolation/expected/global-temp-table.out
@@ -0,0 +1,125 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_insert s2_insert s1_select s2_select
+step s1_insert: INSERT INTO gtt_iso VALUES (1), (2), (3);
+step s2_insert: INSERT INTO gtt_iso VALUES (10), (20), (30);
+step s1_select: SELECT a FROM gtt_iso ORDER BY a;
+a
+-
+1
+2
+3
+(3 rows)
+
+step s2_select: SELECT a FROM gtt_iso ORDER BY a;
+ a
+--
+10
+20
+30
+(3 rows)
+
+
+starting permutation: s1_ocdr_xact s2_ocdr_xact s1_ocdr_select s2_ocdr_select
+step s1_ocdr_xact: BEGIN; INSERT INTO gtt_ocdr VALUES (1), (2); COMMIT;
+step s2_ocdr_xact: BEGIN; INSERT INTO gtt_ocdr VALUES (10), (20); COMMIT;
+step s1_ocdr_select: SELECT x FROM gtt_ocdr ORDER BY x;
+x
+-
+(0 rows)
+
+step s2_ocdr_select: SELECT x FROM gtt_ocdr ORDER BY x;
+x
+-
+(0 rows)
+
+
+starting permutation: s1_ocdr_analyze s1_ocdr_stats_in s1_ocdr_commit s1_ocdr_stats_out s1_ocdr_select
+step s1_ocdr_analyze: BEGIN; INSERT INTO gtt_ocdr VALUES (5), (6), (7);
+                          ANALYZE gtt_ocdr;
+step s1_ocdr_stats_in: SELECT count(*) AS in_xact_stats
+                          FROM pg_gtt_relstats()
+                          WHERE table_name = 'gtt_ocdr';
+in_xact_stats
+-------------
+            1
+(1 row)
+
+step s1_ocdr_commit: COMMIT;
+step s1_ocdr_stats_out: SELECT count(*) AS post_commit_stats
+                          FROM pg_gtt_relstats()
+                          WHERE table_name = 'gtt_ocdr';
+post_commit_stats
+-----------------
+                0
+(1 row)
+
+step s1_ocdr_select: SELECT x FROM gtt_ocdr ORDER BY x;
+x
+-
+(0 rows)
+
+
+starting permutation: s1_create_drop s1_begin s1_access_drop s2_drop s1_rollback
+step s1_create_drop: CREATE GLOBAL TEMPORARY TABLE gtt_ddl (x int);
+step s1_begin: BEGIN;
+step s1_access_drop: INSERT INTO gtt_ddl VALUES (1);
+step s2_drop: DROP TABLE gtt_ddl; <waiting ...>
+step s1_rollback: ROLLBACK;
+step s2_drop: <... completed>
+
+starting permutation: s1_create_alter s1_begin s1_access_alter s2_alter s1_rollback
+step s1_create_alter: CREATE GLOBAL TEMPORARY TABLE gtt_alter (a int);
+step s1_begin: BEGIN;
+step s1_access_alter: INSERT INTO gtt_alter VALUES (1), (NULL);
+step s2_alter: ALTER TABLE gtt_alter ALTER COLUMN a SET NOT NULL; <waiting ...>
+step s1_rollback: ROLLBACK;
+step s2_alter: <... completed>
+
+starting permutation: s1_create_idx s1_begin s1_access_idx s2_create_idx s1_rollback
+step s1_create_idx: CREATE GLOBAL TEMPORARY TABLE gtt_idx (a int);
+step s1_begin: BEGIN;
+step s1_access_idx: INSERT INTO gtt_idx VALUES (1), (1);
+step s2_create_idx: CREATE UNIQUE INDEX ON gtt_idx (a); <waiting ...>
+step s1_rollback: ROLLBACK;
+step s2_create_idx: <... completed>
+
+starting permutation: s1_create_drop_c s1_insert_drop_c s2_drop_c
+step s1_create_drop_c: CREATE GLOBAL TEMPORARY TABLE gtt_ddl_c (x int);
+step s1_insert_drop_c: INSERT INTO gtt_ddl_c VALUES (1);
+step s2_drop_c: DROP TABLE gtt_ddl_c;
+ERROR:  cannot drop global temporary table: another session has live per-session data
+
+starting permutation: s1_create_lazy s1_explain_lazy s1_select_lazy s2_alter_lazy s2_drop_lazy
+step s1_create_lazy: CREATE GLOBAL TEMPORARY TABLE gtt_lazy (a int);
+step s1_explain_lazy: EXPLAIN (COSTS OFF) SELECT * FROM gtt_lazy;
+QUERY PLAN          
+--------------------
+Seq Scan on gtt_lazy
+(1 row)
+
+step s1_select_lazy: SELECT count(*) FROM gtt_lazy;
+count
+-----
+    0
+(1 row)
+
+step s2_alter_lazy: ALTER TABLE gtt_lazy ADD COLUMN b int;
+step s2_drop_lazy: DROP TABLE gtt_lazy;
+
+starting permutation: s1_create_disc s1_insert_disc s1_discard s2_drop_disc
+step s1_create_disc: CREATE GLOBAL TEMPORARY TABLE gtt_disc_iso (a int);
+step s1_insert_disc: INSERT INTO gtt_disc_iso VALUES (1);
+step s1_discard: DISCARD ALL;
+step s2_drop_disc: DROP TABLE gtt_disc_iso;
+
+starting permutation: s1_create_idxscan s1_scan_idxscan s2_drop_idxscan
+step s1_create_idxscan: CREATE GLOBAL TEMPORARY TABLE gtt_idxscan (a int PRIMARY KEY);
+step s1_scan_idxscan: SET enable_seqscan = off;
+                        SELECT * FROM gtt_idxscan WHERE a = 1;
+a
+-
+(0 rows)
+
+step s2_drop_idxscan: DROP TABLE gtt_idxscan;
+ERROR:  cannot drop global temporary table: another session has live per-session data
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..2152f6c7cd5 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -48,6 +48,7 @@ test: lock-update-delete
 test: lock-update-traversal
 test: inherit-temp
 test: temp-schema-cleanup
+test: global-temp-table
 test: insert-conflict-do-nothing
 test: insert-conflict-do-nothing-2
 test: insert-conflict-do-update
diff --git a/src/test/isolation/specs/global-temp-table.spec b/src/test/isolation/specs/global-temp-table.spec
new file mode 100644
index 00000000000..25306dd35ca
--- /dev/null
+++ b/src/test/isolation/specs/global-temp-table.spec
@@ -0,0 +1,148 @@
+# Tests for global temporary tables across concurrent sessions.
+#
+# Two aspects are covered here:
+#   1. Cross-session data isolation: the catalog definition is shared
+#      but each session has its own rows.
+#   2. DDL safety: while one session has materialized per-session data,
+#      another session's DROP, ALTER, or CREATE INDEX must not succeed.
+#      A shared-memory sessions registry keyed on (dboid, relid,
+#      ProcNumber) provides this: GttCheckDroppable and
+#      GttCheckAlterable consult it while the caller holds
+#      AccessExclusiveLock and error out if any other backend has live
+#      storage.  No session-level heavyweight lock is involved, so the
+#      DDL fails promptly instead of blocking until the peer
+#      disconnects.
+#
+#      Storage is created lazily: a session registers only when it first
+#      writes (or index-scans) a GTT, and an abort of the materializing
+#      transaction unlinks the files and deregisters.  So DDL against
+#      peers that merely opened, planned, or read a GTT -- or whose only
+#      writes were rolled back -- succeeds, while committed data still
+#      blocks it.
+#
+# The DDL-safety permutations have s1 CREATE its own GTT rather than
+# using the setup connection, so that only s1 can own a registry entry.
+#
+# Each DDL-safety permutation uses a distinct GTT name because session
+# state persists across permutations and there is no teardown DROP;
+# reusing a name would clash with the prior permutation's leftover
+# relation.  The temp instance is destroyed after the test anyway.
+
+setup
+{
+  CREATE GLOBAL TEMPORARY TABLE IF NOT EXISTS gtt_iso (a int);
+  CREATE GLOBAL TEMPORARY TABLE IF NOT EXISTS gtt_ocdr (x int)
+    ON COMMIT DELETE ROWS;
+}
+
+session s1
+step s1_insert        { INSERT INTO gtt_iso VALUES (1), (2), (3); }
+step s1_select        { SELECT a FROM gtt_iso ORDER BY a; }
+# ON COMMIT DELETE ROWS in a non-creating session.  s1 inserts into a
+# GTT created by the setup connection and commits; the per-session file
+# must be truncated even though s1 was not the session that created the
+# GTT.
+step s1_ocdr_xact     { BEGIN; INSERT INTO gtt_ocdr VALUES (1), (2); COMMIT; }
+step s1_ocdr_select   { SELECT x FROM gtt_ocdr ORDER BY x; }
+# Stats-and-data interaction in a non-creating session.  Pre-fix, the
+# data wasn't truncated at COMMIT in this session but PreCommit_gtt_on_commit
+# still cleared the per-session ANALYZE results, leaving the planner to
+# re-estimate against rows that were still on disk.  After the fix data
+# and stats are wiped together at COMMIT.
+step s1_ocdr_analyze    { BEGIN; INSERT INTO gtt_ocdr VALUES (5), (6), (7);
+                          ANALYZE gtt_ocdr; }
+step s1_ocdr_stats_in   { SELECT count(*) AS in_xact_stats
+                          FROM pg_gtt_relstats()
+                          WHERE table_name = 'gtt_ocdr'; }
+step s1_ocdr_commit     { COMMIT; }
+step s1_ocdr_stats_out  { SELECT count(*) AS post_commit_stats
+                          FROM pg_gtt_relstats()
+                          WHERE table_name = 'gtt_ocdr'; }
+# s1 creates each DDL-test GTT so that only s1 holds the session lock.
+step s1_create_drop   { CREATE GLOBAL TEMPORARY TABLE gtt_ddl (x int); }
+step s1_create_alter  { CREATE GLOBAL TEMPORARY TABLE gtt_alter (a int); }
+step s1_create_idx    { CREATE GLOBAL TEMPORARY TABLE gtt_idx (a int); }
+step s1_create_drop_c { CREATE GLOBAL TEMPORARY TABLE gtt_ddl_c (x int); }
+step s1_insert_drop_c { INSERT INTO gtt_ddl_c VALUES (1); }
+step s1_create_lazy   { CREATE GLOBAL TEMPORARY TABLE gtt_lazy (a int); }
+step s1_explain_lazy  { EXPLAIN (COSTS OFF) SELECT * FROM gtt_lazy; }
+step s1_select_lazy   { SELECT count(*) FROM gtt_lazy; }
+step s1_create_disc   { CREATE GLOBAL TEMPORARY TABLE gtt_disc_iso (a int); }
+step s1_insert_disc   { INSERT INTO gtt_disc_iso VALUES (1); }
+step s1_discard       { DISCARD ALL; }
+step s1_create_idxscan { CREATE GLOBAL TEMPORARY TABLE gtt_idxscan (a int PRIMARY KEY); }
+step s1_scan_idxscan  { SET enable_seqscan = off;
+                        SELECT * FROM gtt_idxscan WHERE a = 1; }
+step s1_begin         { BEGIN; }
+step s1_access_drop   { INSERT INTO gtt_ddl VALUES (1); }
+# Insert data that would invalidate a SET NOT NULL constraint on column a.
+step s1_access_alter  { INSERT INTO gtt_alter VALUES (1), (NULL); }
+# Insert data that would fail a UNIQUE-index build on column a.
+step s1_access_idx    { INSERT INTO gtt_idx VALUES (1), (1); }
+# ROLLBACK unlinks the files the aborted transaction materialized and
+# removes s1's registry entries, so a waiting peer DDL then succeeds.
+step s1_rollback      { ROLLBACK; }
+
+session s2
+step s2_insert        { INSERT INTO gtt_iso VALUES (10), (20), (30); }
+step s2_select        { SELECT a FROM gtt_iso ORDER BY a; }
+# Mirror of s1's ON COMMIT DELETE ROWS steps, run from a different
+# session that also is not the GTT's creator.
+step s2_ocdr_xact     { BEGIN; INSERT INTO gtt_ocdr VALUES (10), (20); COMMIT; }
+step s2_ocdr_select   { SELECT x FROM gtt_ocdr ORDER BY x; }
+# Peer-session DDL; each blocks on s1's transaction-level lock while
+# s1's transaction is open, then either succeeds (s1 rolled back, so
+# its registration is gone) or fails on the shared-memory
+# sessions-registry check (s1 committed data).
+step s2_drop          { DROP TABLE gtt_ddl; }
+step s2_alter         { ALTER TABLE gtt_alter ALTER COLUMN a SET NOT NULL; }
+step s2_create_idx    { CREATE UNIQUE INDEX ON gtt_idx (a); }
+step s2_drop_c        { DROP TABLE gtt_ddl_c; }
+step s2_drop_lazy     { DROP TABLE gtt_lazy; }
+step s2_alter_lazy    { ALTER TABLE gtt_lazy ADD COLUMN b int; }
+step s2_drop_disc     { DROP TABLE gtt_disc_iso; }
+step s2_drop_idxscan  { DROP TABLE gtt_idxscan; }
+
+# Data isolation: each session sees only its own rows.
+permutation s1_insert s2_insert s1_select s2_select
+
+# ON COMMIT DELETE ROWS must fire for sessions that did not create the
+# GTT.  Each session registers its own OnCommitItem from
+# GttInitSessionStorage, independently of the creating session's setup.
+permutation s1_ocdr_xact s2_ocdr_xact s1_ocdr_select s2_ocdr_select
+
+# In the non-creator session, stats and data must be cleared in lockstep
+# at COMMIT.  Pre-fix the data lingered while the stats were wiped, so
+# the planner re-estimated against rows that were still present.
+permutation s1_ocdr_analyze s1_ocdr_stats_in s1_ocdr_commit
+            s1_ocdr_stats_out s1_ocdr_select
+
+# DDL safety - DROP after rollback: s2's DROP waits on s1's open
+# transaction; s1's ROLLBACK discards the materialized storage and the
+# registration, so the DROP then succeeds.
+permutation s1_create_drop s1_begin s1_access_drop s2_drop s1_rollback
+
+# DDL safety - ALTER after rollback: same shape; the rolled-back NULL
+# rows no longer exist, so SET NOT NULL may proceed.
+permutation s1_create_alter s1_begin s1_access_alter s2_alter s1_rollback
+
+# DDL safety - CREATE UNIQUE INDEX after rollback: the rolled-back
+# duplicates no longer exist, so the index may be created.
+permutation s1_create_idx s1_begin s1_access_idx s2_create_idx s1_rollback
+
+# DDL safety - committed data blocks DROP: s1's registration persists
+# after commit, so s2's DROP fails on the registry check.
+permutation s1_create_drop_c s1_insert_drop_c s2_drop_c
+
+# Lazy materialization: merely planning or reading a GTT does not
+# register the session, so peer DDL proceeds without delay.
+permutation s1_create_lazy s1_explain_lazy s1_select_lazy s2_alter_lazy s2_drop_lazy
+
+# A committed DISCARD releases the session's storage and registration:
+# peer DROP succeeds without delay against an idle pooled session.
+permutation s1_create_disc s1_insert_disc s1_discard s2_drop_disc
+
+# An index materialized by a scan (even over an unmaterialized heap)
+# counts as live storage for the owning table: peer DROP is refused, so
+# no session can be left holding storage for a vanished catalog entry.
+permutation s1_create_idxscan s1_scan_idxscan s2_drop_idxscan
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index 969e90b396d..867ee80ff1b 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -22,6 +22,7 @@ tests += {
       't/011_lock_stats.pl',
       't/012_ddlutils.pl',
       't/013_temp_obj_multisession.pl',
+      't/014_gtt_xid_horizon.pl',
     ],
     # The injection points are cluster-wide, so disable installcheck
     'runningcheck': false,
diff --git a/src/test/modules/test_misc/t/014_gtt_xid_horizon.pl b/src/test/modules/test_misc/t/014_gtt_xid_horizon.pl
new file mode 100644
index 00000000000..4752ea448b8
--- /dev/null
+++ b/src/test/modules/test_misc/t/014_gtt_xid_horizon.pl
@@ -0,0 +1,132 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+# Verify the transaction-ID horizon guard for global temporary tables:
+# session-local GTT data contributes nothing to datfrozenxid, so unless the
+# session freezes it (by VACUUMing the table) its oldest row can fall behind
+# the cluster commit-log truncation horizon and no longer be readable.
+# Accessing such a table must be refused with an error (fail closed) rather
+# than risk a wrong result, and a warning must be issued as the data approaches
+# the horizon.  VACUUM is the escape hatch: it freezes the data in place and
+# lifts the block without losing it.  The hard error and warning cannot be
+# triggered deterministically in the standard regression suite, so they are
+# exercised here on a dedicated cluster whose frozen horizon we advance by hand.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('gtt_xid_horizon');
+$node->init;
+# Disable autovacuum so the frozen horizon only moves when we say so, and make
+# VACUUM FREEZE advance relfrozenxid all the way to the current xmin.
+$node->append_conf(
+	'postgresql.conf', q{
+autovacuum = off
+vacuum_freeze_min_age = 0
+vacuum_freeze_table_age = 0
+});
+$node->start;
+
+# Advance the cluster-wide CLOG-truncation horizon (TransamVariables->oldestXid,
+# the minimum datfrozenxid across all databases) past all current data by
+# freezing every database, including template0.
+sub freeze_all_databases
+{
+	$node->safe_psql('postgres',
+		"UPDATE pg_database SET datallowconn = true WHERE datname = 'template0'"
+	);
+	$node->safe_psql($_, 'VACUUM (FREEZE)')
+	  for (qw(template0 template1 postgres));
+	$node->safe_psql('postgres',
+		"UPDATE pg_database SET datallowconn = false WHERE datname = 'template0'"
+	);
+}
+
+# A persistent session.  GTT data and its horizon tracking are per-session, so
+# everything that observes them must run on this one connection.
+my $s = $node->background_psql('postgres', on_error_stop => 0);
+
+# Server-side helper to consume transaction IDs (advance the next XID) without
+# needing a fresh connection per XID.
+$s->query_safe(
+	q{CREATE PROCEDURE burn(n int) LANGUAGE plpgsql AS $$
+	  BEGIN FOR i IN 1..n LOOP PERFORM pg_current_xact_id(); COMMIT; END LOOP; END $$});
+
+$s->query_safe("CREATE GLOBAL TEMPORARY TABLE g (a int)");
+$s->query_safe("INSERT INTO g VALUES (1)");
+
+# 1. Default margin: freshly written data must not warn.
+$s->{stderr} = '';
+$s->query("SELECT count(*) FROM g");
+is($s->{stderr}, '',
+	'no false-positive warning at default margin on fresh data');
+
+# 2. As the data ages toward the horizon (small margin, many XIDs burned so
+#    the CLOG history exceeds it), a warning is issued -- once.
+$s->query_safe("SET global_temp_xid_warn_margin = 100");
+$s->query_safe("CALL burn(500)");
+$s->{stderr} = '';
+$s->query("SELECT count(*) FROM g");
+like(
+	$s->{stderr},
+	qr/approaching the transaction-ID horizon/,
+	'warning fires as GTT data approaches the horizon');
+
+$s->{stderr} = '';
+$s->query("SELECT count(*) FROM g");
+is($s->{stderr}, '',
+	'approaching-horizon warning is throttled to once per relation');
+
+# 3. Once the horizon is advanced past the data, access is refused.
+freeze_all_databases();
+$s->{stderr} = '';
+$s->query("SELECT count(*) FROM g");
+like(
+	$s->{stderr},
+	qr/older than the transaction-ID horizon/,
+	'reading GTT data older than the horizon is refused with an error');
+
+# The write path is guarded too.
+$s->{stderr} = '';
+$s->query("INSERT INTO g VALUES (2)");
+like(
+	$s->{stderr},
+	qr/older than the transaction-ID horizon/,
+	'writing to a GTT whose data is older than the horizon is refused');
+
+# 4. VACUUM is the escape hatch.  It does not go through the guarded read/write
+#    entry points, so it runs even while plain access is being refused; it
+#    freezes this session's data in place (the rows were read above, so their
+#    commit-status hint bits are set and freezing needs no truncated CLOG) and
+#    advances the per-session relfrozenxid past the horizon.  The table is then
+#    readable and writable again with no data lost.  (Reset the accumulated
+#    stderr from the expected errors above first.)
+$s->{stderr} = '';
+$s->query_safe("VACUUM g");
+$s->{stderr} = '';
+is($s->query("SELECT count(*) FROM g"),
+	'1', 'VACUUM freezes a trapped GTT in place; its single row survives');
+is($s->{stderr}, '',
+	'reading the GTT no longer errors once VACUUM has frozen its data');
+$s->{stderr} = '';
+$s->query_safe("INSERT INTO g VALUES (2)");
+is($s->{stderr}, '',
+	'writing to the GTT no longer errors once VACUUM has frozen its data');
+
+# 5. TRUNCATE clears the per-session tracking; fresh data is accessible again
+#    even though the horizon has advanced.
+$s->{stderr} = '';
+$s->query_safe("TRUNCATE g");
+$s->query_safe("INSERT INTO g VALUES (3)");
+$s->{stderr} = '';
+$s->query("SELECT count(*) FROM g");
+is($s->{stderr}, '',
+	'TRUNCATE resets horizon tracking; fresh data is accessible again');
+
+$s->quit;
+$node->stop;
+
+done_testing();
diff --git a/src/test/regress/expected/global_temp.out b/src/test/regress/expected/global_temp.out
new file mode 100644
index 00000000000..59f0f2ee33f
--- /dev/null
+++ b/src/test/regress/expected/global_temp.out
@@ -0,0 +1,2600 @@
+--
+-- Tests for global temporary tables
+--
+-- Basic creation and data isolation
+CREATE GLOBAL TEMPORARY TABLE gtt_basic (a int, b text);
+-- Verify it exists in pg_class with correct persistence
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_basic';
+  relname  | relpersistence 
+-----------+----------------
+ gtt_basic | g
+(1 row)
+
+-- Insert and query data
+INSERT INTO gtt_basic VALUES (1, 'hello'), (2, 'world');
+SELECT * FROM gtt_basic ORDER BY a;
+ a |   b   
+---+-------
+ 1 | hello
+ 2 | world
+(2 rows)
+
+-- Verify local buffer usage (no WAL)
+SELECT count(*) > 0 AS has_data FROM gtt_basic;
+ has_data 
+----------
+ t
+(1 row)
+
+-- GLOBAL TEMP shorthand
+CREATE GLOBAL TEMP TABLE gtt_short (x int);
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_short';
+  relname  | relpersistence 
+-----------+----------------
+ gtt_short | g
+(1 row)
+
+DROP TABLE gtt_short;
+-- Verify the table is visible after reconnect (definition persists)
+\c -
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_basic';
+  relname  | relpersistence 
+-----------+----------------
+ gtt_basic | g
+(1 row)
+
+-- But data should be gone (new session)
+SELECT * FROM gtt_basic ORDER BY a;
+ a | b 
+---+---
+(0 rows)
+
+-- Re-insert for further tests
+INSERT INTO gtt_basic VALUES (10, 'new session');
+SELECT * FROM gtt_basic ORDER BY a;
+ a  |      b      
+----+-------------
+ 10 | new session
+(1 row)
+
+--
+-- Indexes
+--
+CREATE INDEX gtt_basic_idx ON gtt_basic (a);
+-- Verify index is usable
+SELECT * FROM gtt_basic WHERE a = 10;
+ a  |      b      
+----+-------------
+ 10 | new session
+(1 row)
+
+-- Index is in catalog with correct persistence
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_basic_idx';
+    relname    | relpersistence 
+---------------+----------------
+ gtt_basic_idx | g
+(1 row)
+
+-- Insert more and verify index scan still works
+INSERT INTO gtt_basic VALUES (20, 'indexed');
+SELECT * FROM gtt_basic WHERE a = 20;
+ a  |    b    
+----+---------
+ 20 | indexed
+(1 row)
+
+--
+-- ON COMMIT PRESERVE ROWS (default)
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_preserve (x int);
+BEGIN;
+INSERT INTO gtt_preserve VALUES (1), (2), (3);
+COMMIT;
+-- Data should survive commit
+SELECT * FROM gtt_preserve ORDER BY x;
+ x 
+---
+ 1
+ 2
+ 3
+(3 rows)
+
+DROP TABLE gtt_preserve;
+-- Explicit ON COMMIT PRESERVE ROWS
+CREATE GLOBAL TEMPORARY TABLE gtt_preserve2 (x int) ON COMMIT PRESERVE ROWS;
+BEGIN;
+INSERT INTO gtt_preserve2 VALUES (1), (2), (3);
+COMMIT;
+SELECT * FROM gtt_preserve2 ORDER BY x;
+ x 
+---
+ 1
+ 2
+ 3
+(3 rows)
+
+DROP TABLE gtt_preserve2;
+--
+-- ON COMMIT DELETE ROWS
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_delete (x int) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_delete VALUES (1), (2), (3);
+-- Data visible within transaction
+SELECT * FROM gtt_delete ORDER BY x;
+ x 
+---
+ 1
+ 2
+ 3
+(3 rows)
+
+COMMIT;
+-- Data should be gone after commit
+SELECT * FROM gtt_delete ORDER BY x;
+ x 
+---
+(0 rows)
+
+-- Works across multiple transactions
+BEGIN;
+INSERT INTO gtt_delete VALUES (10);
+COMMIT;
+SELECT * FROM gtt_delete ORDER BY x;
+ x 
+---
+(0 rows)
+
+-- ON COMMIT DELETE ROWS with indexes
+CREATE INDEX gtt_delete_idx ON gtt_delete (x);
+BEGIN;
+INSERT INTO gtt_delete VALUES (100), (200);
+SELECT * FROM gtt_delete ORDER BY x;
+  x  
+-----
+ 100
+ 200
+(2 rows)
+
+COMMIT;
+-- Data gone, index still works for next transaction
+SELECT * FROM gtt_delete ORDER BY x;
+ x 
+---
+(0 rows)
+
+BEGIN;
+INSERT INTO gtt_delete VALUES (300);
+SELECT * FROM gtt_delete WHERE x = 300;
+  x  
+-----
+ 300
+(1 row)
+
+COMMIT;
+DROP TABLE gtt_delete;
+-- A first use that consists of INSERT followed by ROLLBACK must leave the
+-- GTT usable in subsequent transactions: the abort path that removes the
+-- per-session storage hash entry (and unlinks the file via PendingRelDelete)
+-- has to invalidate the relcache so the next access re-runs
+-- GttInitSessionStorage and re-creates the file.
+CREATE GLOBAL TEMPORARY TABLE gtt_abort_recreate (x int) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_abort_recreate VALUES (1);
+ROLLBACK;
+SELECT * FROM gtt_abort_recreate;
+ x 
+---
+(0 rows)
+
+BEGIN;
+INSERT INTO gtt_abort_recreate VALUES (99);
+SELECT * FROM gtt_abort_recreate;
+ x  
+----
+ 99
+(1 row)
+
+COMMIT;
+SELECT * FROM gtt_abort_recreate;  -- empty (ON COMMIT DELETE ROWS)
+ x 
+---
+(0 rows)
+
+DROP TABLE gtt_abort_recreate;
+--
+-- ON COMMIT DROP is not allowed for GTTs
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_nodrop (x int) ON COMMIT DROP;  -- ERROR
+ERROR:  ON COMMIT DROP is not supported for global temporary tables
+--
+-- DDL restrictions
+--
+-- Cannot ALTER to LOGGED or UNLOGGED
+CREATE GLOBAL TEMPORARY TABLE gtt_noalter (x int);
+ALTER TABLE gtt_noalter SET LOGGED;      -- ERROR
+ERROR:  cannot change logged status of table "gtt_noalter" because it is a global temporary table
+ALTER TABLE gtt_noalter SET UNLOGGED;    -- ERROR
+ERROR:  cannot change logged status of table "gtt_noalter" because it is a global temporary table
+DROP TABLE gtt_noalter;
+-- CLUSTER, REPACK, and REINDEX are not supported (would change shared relfilenode)
+CREATE GLOBAL TEMPORARY TABLE gtt_nocluster (x int);
+CREATE INDEX gtt_nocluster_idx ON gtt_nocluster (x);
+CLUSTER gtt_nocluster USING gtt_nocluster_idx;  -- ERROR
+ERROR:  cannot execute CLUSTER on global temporary tables
+REPACK gtt_nocluster;                            -- ERROR
+ERROR:  cannot execute REPACK on global temporary tables
+REPACK (CONCURRENTLY) gtt_nocluster;             -- ERROR
+ERROR:  cannot execute REPACK on global temporary tables
+REINDEX TABLE gtt_nocluster;                     -- ERROR
+ERROR:  cannot reindex global temporary tables
+REINDEX INDEX gtt_nocluster_idx;                 -- ERROR
+ERROR:  cannot reindex global temporary tables
+REINDEX TABLE CONCURRENTLY gtt_nocluster;        -- NOTICE (falls back to non-concurrent), ERROR
+NOTICE:  REINDEX CONCURRENTLY is not supported for global temporary tables
+DETAIL:  Falling back to a non-concurrent reindex.
+ERROR:  cannot reindex global temporary tables
+REINDEX INDEX CONCURRENTLY gtt_nocluster_idx;    -- NOTICE (falls back to non-concurrent), ERROR
+NOTICE:  REINDEX CONCURRENTLY is not supported for global temporary tables
+DETAIL:  Falling back to a non-concurrent reindex.
+ERROR:  cannot reindex global temporary tables
+-- VACUUM FULL is rejected (it would reassign the shared relfilenode); plain
+-- VACUUM is the supported way to maintain a GTT (see below)
+VACUUM FULL gtt_nocluster;
+ERROR:  cannot execute VACUUM FULL on global temporary tables
+HINT:  Plain VACUUM freezes a global temporary table's session-local data in place.
+DROP TABLE gtt_nocluster;
+-- Database- and partitioned-wide bulk commands silently skip GTTs rather
+-- than aborting on the first one.  We can't exercise REPACK; (no target)
+-- here because it disallows running inside a transaction block, but we
+-- can verify the partitioned-GTT and REINDEX SCHEMA paths.
+CREATE SCHEMA gtt_bulk;
+CREATE GLOBAL TEMPORARY TABLE gtt_bulk.gtt_part (x int) PARTITION BY RANGE (x);
+CREATE GLOBAL TEMPORARY TABLE gtt_bulk.gtt_part_1 PARTITION OF gtt_bulk.gtt_part
+    FOR VALUES FROM (0) TO (100);
+REPACK gtt_bulk.gtt_part;                        -- ERROR (clean message at parent)
+ERROR:  cannot execute REPACK on global temporary tables
+REINDEX SCHEMA gtt_bulk;                         -- no error: GTTs silently skipped
+DROP SCHEMA gtt_bulk CASCADE;
+NOTICE:  drop cascades to table gtt_bulk.gtt_part
+-- CREATE STATISTICS is not supported (would require per-session extended stats)
+CREATE GLOBAL TEMPORARY TABLE gtt_nostats (a int, b int);
+CREATE STATISTICS gtt_nostats_stats ON a, b FROM gtt_nostats;  -- ERROR
+ERROR:  cannot define statistics for global temporary table "gtt_nostats"
+DROP TABLE gtt_nostats;
+-- CREATE TABLE ... (LIKE ...): a GTT cannot carry extended statistics, so
+-- INCLUDING STATISTICS (and INCLUDING ALL) skips them with a warning rather
+-- than failing the command.
+CREATE TABLE gtt_like_src (a int, b int);
+CREATE STATISTICS gtt_like_src_stats ON a, b FROM gtt_like_src;
+CREATE GLOBAL TEMPORARY TABLE gtt_like_all (LIKE gtt_like_src INCLUDING ALL);  -- WARNING
+WARNING:  statistics objects not copied to global temporary table "gtt_like_all"
+DETAIL:  Global temporary tables cannot have extended statistics.
+SELECT count(*) AS extstats_on_gtt
+    FROM pg_statistic_ext WHERE stxrelid = 'gtt_like_all'::regclass;
+ extstats_on_gtt 
+-----------------
+               0
+(1 row)
+
+-- a plain LIKE (no statistics requested) is unaffected
+CREATE GLOBAL TEMPORARY TABLE gtt_like_plain (LIKE gtt_like_src);
+DROP TABLE gtt_like_all, gtt_like_plain, gtt_like_src;
+-- CREATE INDEX CONCURRENTLY falls back to non-concurrent
+CREATE GLOBAL TEMPORARY TABLE gtt_cic (x int);
+INSERT INTO gtt_cic VALUES (1), (2), (3);
+CREATE INDEX CONCURRENTLY gtt_cic_idx ON gtt_cic (x);
+NOTICE:  CREATE INDEX CONCURRENTLY is not supported for global temporary tables
+DETAIL:  Falling back to a non-concurrent build.
+-- Verify it works
+SELECT * FROM gtt_cic WHERE x = 2;
+ x 
+---
+ 2
+(1 row)
+
+-- DROP INDEX CONCURRENTLY likewise falls back to non-concurrent: the
+-- multi-transaction protocol would mark the index invalid in the shared
+-- catalog while peers' per-session storage is unaffected.
+DROP INDEX CONCURRENTLY gtt_cic_idx;
+NOTICE:  DROP INDEX CONCURRENTLY is not supported for global temporary tables
+DETAIL:  Falling back to a non-concurrent drop.
+SELECT * FROM gtt_cic WHERE x = 2;
+ x 
+---
+ 2
+(1 row)
+
+DROP TABLE gtt_cic;
+-- VACUUM freezes a GTT's session-local data in place.  The new freeze
+-- horizon is kept per session; the shared pg_class row keeps invalid
+-- relfrozenxid/relminmxid (it cannot describe any one session's data).
+CREATE GLOBAL TEMPORARY TABLE gtt_vacuum (x int);
+-- Without session data there is nothing to freeze, so VACUUM is skipped.
+VACUUM gtt_vacuum;
+INFO:  skipping vacuum of "gtt_vacuum" --- this session has no data for the global temporary table
+INSERT INTO gtt_vacuum VALUES (1), (2), (3);
+-- With data, VACUUM and VACUUM FREEZE run against the session-local storage.
+VACUUM gtt_vacuum;
+VACUUM (FREEZE) gtt_vacuum;
+-- Data still accessible after vacuuming/freezing.
+SELECT count(*) FROM gtt_vacuum;
+ count 
+-------
+     3
+(1 row)
+
+-- The shared catalog row is untouched: freeze state lives per session.
+SELECT relfrozenxid, relminmxid FROM pg_class WHERE relname = 'gtt_vacuum';
+ relfrozenxid | relminmxid 
+--------------+------------
+            0 |          0
+(1 row)
+
+-- VACUUM ANALYZE runs both the vacuum and the (session-local) analyze.
+VACUUM ANALYZE gtt_vacuum;
+SELECT count(*) FROM gtt_vacuum;
+ count 
+-------
+     3
+(1 row)
+
+DROP TABLE gtt_vacuum;
+-- FK: GTT can reference another GTT
+CREATE GLOBAL TEMPORARY TABLE gtt_pk (id int PRIMARY KEY);
+CREATE GLOBAL TEMPORARY TABLE gtt_fk (id int REFERENCES gtt_pk(id));
+DROP TABLE gtt_fk;
+DROP TABLE gtt_pk;
+-- FK: GTT cannot reference permanent table
+CREATE TABLE perm_pk (id int PRIMARY KEY);
+CREATE GLOBAL TEMPORARY TABLE gtt_fk_bad (id int REFERENCES perm_pk(id));  -- ERROR
+ERROR:  constraints on global temporary tables may reference only global temporary tables
+DROP TABLE perm_pk;
+-- FK: permanent table cannot reference GTT
+CREATE GLOBAL TEMPORARY TABLE gtt_pk2 (id int PRIMARY KEY);
+CREATE TABLE perm_fk_bad (id int REFERENCES gtt_pk2(id));  -- ERROR
+ERROR:  constraints on permanent tables may reference only permanent tables
+DROP TABLE gtt_pk2;
+-- Inheritance restrictions: cannot mix GTT and local temp
+CREATE GLOBAL TEMPORARY TABLE gtt_parent (x int);
+CREATE TEMPORARY TABLE local_child () INHERITS (gtt_parent);  -- ERROR
+ERROR:  cannot mix global temporary and local temporary tables in inheritance
+CREATE TEMPORARY TABLE local_parent (x int);
+CREATE GLOBAL TEMPORARY TABLE gtt_child () INHERITS (local_parent);  -- ERROR
+ERROR:  cannot mix global temporary and local temporary tables in inheritance
+DROP TABLE local_parent;
+-- Inheritance restrictions: cannot mix GTT and permanent
+CREATE TABLE perm_child () INHERITS (gtt_parent);  -- ERROR
+ERROR:  cannot inherit from temporary relation "gtt_parent"
+-- GTT can inherit from another GTT
+CREATE GLOBAL TEMPORARY TABLE gtt_child2 () INHERITS (gtt_parent);
+INSERT INTO gtt_child2 VALUES (42);
+SELECT * FROM gtt_parent;  -- should see child's data
+ x  
+----
+ 42
+(1 row)
+
+DROP TABLE gtt_child2;
+DROP TABLE gtt_parent;
+-- Views on GTTs see per-session data
+CREATE GLOBAL TEMPORARY TABLE gtt_viewtest (id int, val text);
+CREATE VIEW gtt_view AS SELECT * FROM gtt_viewtest;
+INSERT INTO gtt_viewtest VALUES (1, 'hello'), (2, 'world');
+SELECT * FROM gtt_view ORDER BY id;
+ id |  val  
+----+-------
+  1 | hello
+  2 | world
+(2 rows)
+
+DROP VIEW gtt_view;
+DROP TABLE gtt_viewtest;
+-- Triggers on GTTs
+CREATE GLOBAL TEMPORARY TABLE gtt_trigger (id int, val text);
+CREATE FUNCTION gtt_trigger_fn() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+    NEW.val := NEW.val || ' (triggered)';
+    RETURN NEW;
+END;
+$$;
+CREATE TRIGGER gtt_trg BEFORE INSERT ON gtt_trigger
+    FOR EACH ROW EXECUTE FUNCTION gtt_trigger_fn();
+INSERT INTO gtt_trigger VALUES (1, 'test');
+SELECT * FROM gtt_trigger;
+ id |       val        
+----+------------------
+  1 | test (triggered)
+(1 row)
+
+DROP TABLE gtt_trigger;
+DROP FUNCTION gtt_trigger_fn();
+-- SERIAL (sequence) columns on GTTs: the backing sequence is itself a
+-- global temporary sequence so each session gets its own counter.
+CREATE GLOBAL TEMPORARY TABLE gtt_serial (id serial, val text);
+SELECT relname, relpersistence FROM pg_class
+    WHERE relname LIKE 'gtt_serial%' ORDER BY relname;
+      relname      | relpersistence 
+-------------------+----------------
+ gtt_serial        | g
+ gtt_serial_id_seq | g
+(2 rows)
+
+INSERT INTO gtt_serial (val) VALUES ('a'), ('b'), ('c');
+SELECT * FROM gtt_serial ORDER BY id;
+ id | val 
+----+-----
+  1 | a
+  2 | b
+  3 | c
+(3 rows)
+
+-- currval/setval follow the per-session counter
+SELECT currval('gtt_serial_id_seq');
+ currval 
+---------
+       3
+(1 row)
+
+SELECT setval('gtt_serial_id_seq', 100);
+ setval 
+--------
+    100
+(1 row)
+
+INSERT INTO gtt_serial (val) VALUES ('d');
+SELECT * FROM gtt_serial ORDER BY id;
+ id  | val 
+-----+-----
+   1 | a
+   2 | b
+   3 | c
+ 101 | d
+(4 rows)
+
+DROP TABLE gtt_serial;
+-- DROP CASCADE with dependent view
+CREATE GLOBAL TEMPORARY TABLE gtt_deptest (x int);
+CREATE VIEW gtt_depview AS SELECT x FROM gtt_deptest;
+DROP TABLE gtt_deptest;  -- ERROR: view depends on it
+ERROR:  cannot drop table gtt_deptest because other objects depend on it
+DETAIL:  view gtt_depview depends on table gtt_deptest
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP TABLE gtt_deptest CASCADE;  -- OK: drops view too
+NOTICE:  drop cascades to view gtt_depview
+-- Verify the view is gone
+SELECT * FROM gtt_depview;  -- ERROR
+ERROR:  relation "gtt_depview" does not exist
+LINE 1: SELECT * FROM gtt_depview;
+                      ^
+-- Cannot create GTT in temporary schema
+CREATE TEMPORARY TABLE force_temp_schema (x int);
+CREATE GLOBAL TEMPORARY TABLE pg_temp.gtt_in_temp (x int);  -- ERROR
+ERROR:  cannot create a global temporary table in a temporary schema
+LINE 1: CREATE GLOBAL TEMPORARY TABLE pg_temp.gtt_in_temp (x int);
+                                      ^
+DROP TABLE force_temp_schema;
+--
+-- Toast tables
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_toast (id int, data text);
+INSERT INTO gtt_toast VALUES (1, repeat('x', 10000));
+SELECT id, length(data) FROM gtt_toast;
+ id | length 
+----+--------
+  1 |  10000
+(1 row)
+
+DROP TABLE gtt_toast;
+--
+-- Multiple GTTs
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_multi1 (a int);
+CREATE GLOBAL TEMPORARY TABLE gtt_multi2 (b text);
+INSERT INTO gtt_multi1 VALUES (1), (2);
+INSERT INTO gtt_multi2 VALUES ('a'), ('b');
+SELECT * FROM gtt_multi1 ORDER BY a;
+ a 
+---
+ 1
+ 2
+(2 rows)
+
+SELECT * FROM gtt_multi2 ORDER BY b;
+ b 
+---
+ a
+ b
+(2 rows)
+
+DROP TABLE gtt_multi1;
+DROP TABLE gtt_multi2;
+--
+-- TRUNCATE
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_trunc (x int);
+INSERT INTO gtt_trunc VALUES (1), (2), (3);
+SELECT count(*) FROM gtt_trunc;
+ count 
+-------
+     3
+(1 row)
+
+TRUNCATE gtt_trunc;
+SELECT count(*) FROM gtt_trunc;
+ count 
+-------
+     0
+(1 row)
+
+-- Insert after truncate still works
+INSERT INTO gtt_trunc VALUES (10);
+SELECT * FROM gtt_trunc ORDER BY x;
+ x  
+----
+ 10
+(1 row)
+
+DROP TABLE gtt_trunc;
+-- TRUNCATE with indexes
+CREATE GLOBAL TEMPORARY TABLE gtt_trunc2 (x int);
+CREATE INDEX gtt_trunc2_idx ON gtt_trunc2 (x);
+INSERT INTO gtt_trunc2 VALUES (1), (2), (3);
+SELECT * FROM gtt_trunc2 ORDER BY x;
+ x 
+---
+ 1
+ 2
+ 3
+(3 rows)
+
+TRUNCATE gtt_trunc2;
+SELECT * FROM gtt_trunc2 ORDER BY x;
+ x 
+---
+(0 rows)
+
+-- Index works after truncate
+INSERT INTO gtt_trunc2 VALUES (99);
+SELECT * FROM gtt_trunc2 WHERE x = 99;
+ x  
+----
+ 99
+(1 row)
+
+DROP TABLE gtt_trunc2;
+--
+-- COPY
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_copy (a int, b text);
+COPY gtt_copy FROM stdin;
+SELECT * FROM gtt_copy ORDER BY a;
+ a |   b   
+---+-------
+ 1 | alpha
+ 2 | beta
+ 3 | gamma
+(3 rows)
+
+COPY gtt_copy TO stdout;
+1	alpha
+2	beta
+3	gamma
+DROP TABLE gtt_copy;
+--
+-- UPDATE and DELETE
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_dml (id int, val text);
+INSERT INTO gtt_dml VALUES (1, 'one'), (2, 'two'), (3, 'three');
+UPDATE gtt_dml SET val = 'TWO' WHERE id = 2;
+DELETE FROM gtt_dml WHERE id = 3;
+SELECT * FROM gtt_dml ORDER BY id;
+ id | val 
+----+-----
+  1 | one
+  2 | TWO
+(2 rows)
+
+DROP TABLE gtt_dml;
+--
+-- Data survives subtransactions
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact (x int);
+BEGIN;
+INSERT INTO gtt_subxact VALUES (1);
+SAVEPOINT sp1;
+INSERT INTO gtt_subxact VALUES (2);
+ROLLBACK TO sp1;
+INSERT INTO gtt_subxact VALUES (3);
+COMMIT;
+SELECT * FROM gtt_subxact ORDER BY x;
+ x 
+---
+ 1
+ 3
+(2 rows)
+
+DROP TABLE gtt_subxact;
+--
+-- Verify data is session-private via reconnect
+--
+INSERT INTO gtt_basic VALUES (99, 'before reconnect');
+SELECT * FROM gtt_basic ORDER BY a;
+ a  |        b         
+----+------------------
+ 10 | new session
+ 20 | indexed
+ 99 | before reconnect
+(3 rows)
+
+\c -
+-- New session: should not see previous session's data
+SELECT * FROM gtt_basic ORDER BY a;
+ a | b 
+---+---
+(0 rows)
+
+--
+-- Clean up
+--
+DROP TABLE gtt_basic;
+--
+-- GTT sequences: counter is per-session
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_seq_tbl (id serial, v text);
+INSERT INTO gtt_seq_tbl (v) VALUES ('a'), ('b'), ('c');
+SELECT * FROM gtt_seq_tbl ORDER BY id;
+ id | v 
+----+---
+  1 | a
+  2 | b
+  3 | c
+(3 rows)
+
+\c -
+-- Fresh session: table empty, counter restarts at 1.
+SELECT * FROM gtt_seq_tbl ORDER BY id;
+ id | v 
+----+---
+(0 rows)
+
+INSERT INTO gtt_seq_tbl (v) VALUES ('x'), ('y');
+SELECT * FROM gtt_seq_tbl ORDER BY id;
+ id | v 
+----+---
+  1 | x
+  2 | y
+(2 rows)
+
+DROP TABLE gtt_seq_tbl;
+-- Standalone GLOBAL TEMPORARY SEQUENCE: definition persistent, counter per-session.
+CREATE GLOBAL TEMPORARY SEQUENCE gtt_seq START 10 INCREMENT 5;
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_seq';
+ relname | relpersistence 
+---------+----------------
+ gtt_seq | g
+(1 row)
+
+SELECT nextval('gtt_seq'), nextval('gtt_seq'), nextval('gtt_seq');
+ nextval | nextval | nextval 
+---------+---------+---------
+      10 |      15 |      20
+(1 row)
+
+\c -
+-- Fresh session sees the definition and gets its own counter from START.
+SELECT nextval('gtt_seq'), nextval('gtt_seq');
+ nextval | nextval 
+---------+---------
+      10 |      15
+(1 row)
+
+DROP SEQUENCE gtt_seq;
+--
+-- Row Level Security on GTTs
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_rls (id int, visible_to text);
+ALTER TABLE gtt_rls ENABLE ROW LEVEL SECURITY;
+CREATE ROLE regress_gtt_rls_user;
+GRANT SELECT, INSERT ON gtt_rls TO regress_gtt_rls_user;
+CREATE POLICY gtt_rls_policy ON gtt_rls
+    USING (visible_to = current_user);
+-- Insert rows: some for regress_gtt_rls_user, some not
+INSERT INTO gtt_rls VALUES (1, 'regress_gtt_rls_user'), (2, 'other_user'), (3, 'regress_gtt_rls_user');
+-- Table owner bypasses RLS by default
+SELECT * FROM gtt_rls ORDER BY id;
+ id |      visible_to      
+----+----------------------
+  1 | regress_gtt_rls_user
+  2 | other_user
+  3 | regress_gtt_rls_user
+(3 rows)
+
+SET ROLE regress_gtt_rls_user;
+-- Non-owner should only see rows matching the policy
+SELECT * FROM gtt_rls ORDER BY id;
+ id |      visible_to      
+----+----------------------
+  1 | regress_gtt_rls_user
+  3 | regress_gtt_rls_user
+(2 rows)
+
+-- Non-owner insert should work
+INSERT INTO gtt_rls VALUES (4, 'regress_gtt_rls_user');
+-- Should see only matching rows
+SELECT * FROM gtt_rls ORDER BY id;
+ id |      visible_to      
+----+----------------------
+  1 | regress_gtt_rls_user
+  3 | regress_gtt_rls_user
+  4 | regress_gtt_rls_user
+(3 rows)
+
+RESET ROLE;
+-- Owner sees all rows again
+SELECT * FROM gtt_rls ORDER BY id;
+ id |      visible_to      
+----+----------------------
+  1 | regress_gtt_rls_user
+  2 | other_user
+  3 | regress_gtt_rls_user
+  4 | regress_gtt_rls_user
+(4 rows)
+
+DROP TABLE gtt_rls;
+DROP ROLE regress_gtt_rls_user;
+-- A policy on a permanent table may reference a GTT: the GTT's definition is
+-- permanent, so the reference is always resolvable (unlike a local temporary
+-- table, whose definition exists only in its own session).  The subquery is
+-- evaluated against the current session's per-session data.
+CREATE TABLE gtt_rls_perm (a int);
+CREATE GLOBAL TEMPORARY TABLE gtt_rls_ref (a int);
+CREATE POLICY gtt_rls_perm_policy ON gtt_rls_perm AS RESTRICTIVE
+    USING ((SELECT a IS NOT NULL FROM gtt_rls_ref WHERE a = 1));
+ALTER TABLE gtt_rls_perm ENABLE ROW LEVEL SECURITY;
+INSERT INTO gtt_rls_perm VALUES (1);
+DROP TABLE gtt_rls_perm, gtt_rls_ref;
+--
+-- ANALYZE and per-session statistics
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_analyze (id int, val text);
+INSERT INTO gtt_analyze SELECT g, 'row ' || g FROM generate_series(1, 1000) g;
+-- Before ANALYZE: pg_class has default values
+SELECT relpages, reltuples FROM pg_class WHERE relname = 'gtt_analyze';
+ relpages | reltuples 
+----------+-----------
+        0 |        -1
+(1 row)
+
+-- Run ANALYZE
+ANALYZE gtt_analyze;
+-- pg_class should NOT be updated (stats are per-session, not shared)
+SELECT relpages, reltuples FROM pg_class WHERE relname = 'gtt_analyze';
+ relpages | reltuples 
+----------+-----------
+        0 |        -1
+(1 row)
+
+-- Data is queryable after ANALYZE
+SELECT count(*) FROM gtt_analyze WHERE id <= 500;
+ count 
+-------
+   500
+(1 row)
+
+-- ANALYZE with indexes
+CREATE INDEX gtt_analyze_idx ON gtt_analyze (id);
+ANALYZE gtt_analyze;
+SELECT count(*) FROM gtt_analyze WHERE id = 500;
+ count 
+-------
+     1
+(1 row)
+
+-- Column stats should NOT be written to shared pg_statistic
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_analyze'::regclass;
+ count 
+-------
+     0
+(1 row)
+
+-- Column stats should be used by planner (distinct count for id should be ~1000)
+-- Check that stadistinct is reflected in planner estimates
+EXPLAIN (COSTS OFF) SELECT * FROM gtt_analyze WHERE id = 42;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Index Scan using gtt_analyze_idx on gtt_analyze
+   Index Cond: (id = 42)
+(2 rows)
+
+-- TRUNCATE should reset per-session relation stats (column stats are
+-- retained, as pg_statistic is for regular tables)
+TRUNCATE gtt_analyze;
+-- After truncate and re-insert, stats should reflect new data after ANALYZE
+INSERT INTO gtt_analyze SELECT g, 'new ' || g FROM generate_series(1, 100) g;
+ANALYZE gtt_analyze;
+SELECT count(*) FROM gtt_analyze WHERE id <= 50;
+ count 
+-------
+    50
+(1 row)
+
+-- Verify no column stats leaked to pg_statistic after re-ANALYZE
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_analyze'::regclass;
+ count 
+-------
+     0
+(1 row)
+
+-- Verify per-session stats don't survive reconnect
+\c -
+-- New session sees shared pg_class (unchanged by ANALYZE)
+SELECT relpages, reltuples FROM pg_class WHERE relname = 'gtt_analyze';
+ relpages | reltuples 
+----------+-----------
+        0 |        -1
+(1 row)
+
+-- No column stats in new session either
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_analyze'::regclass;
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE gtt_analyze;
+--
+-- Per-session column statistics: detailed tests
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_colstats (id int, category text, val float);
+INSERT INTO gtt_colstats
+    SELECT g, 'cat_' || (g % 5), random() * 100
+    FROM generate_series(1, 10000) g;
+CREATE INDEX ON gtt_colstats (category);
+ANALYZE gtt_colstats;
+-- No column stats in shared catalog
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_colstats'::regclass;
+ count 
+-------
+     0
+(1 row)
+
+-- Planner should use per-session stats for category selectivity
+-- With 5 distinct categories, ~2000 rows per category
+EXPLAIN (COSTS OFF) SELECT * FROM gtt_colstats WHERE category = 'cat_0';
+                      QUERY PLAN                      
+------------------------------------------------------
+ Bitmap Heap Scan on gtt_colstats
+   Recheck Cond: (category = 'cat_0'::text)
+   ->  Bitmap Index Scan on gtt_colstats_category_idx
+         Index Cond: (category = 'cat_0'::text)
+(4 rows)
+
+-- Index stats should also be collected per-session
+SELECT count(*) FROM pg_statistic
+    WHERE starelid = (SELECT oid FROM pg_class WHERE relname = 'gtt_colstats_category_idx');
+ count 
+-------
+     0
+(1 row)
+
+-- ON COMMIT DELETE ROWS should reset column stats
+CREATE GLOBAL TEMPORARY TABLE gtt_colstats_ocd (id int, cat text) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_colstats_ocd SELECT g, 'c' || (g % 3) FROM generate_series(1, 1000) g;
+ANALYZE gtt_colstats_ocd;
+COMMIT;
+-- After commit, data is gone and stats should be invalidated
+-- New data with different distribution
+BEGIN;
+INSERT INTO gtt_colstats_ocd SELECT g, 'x' FROM generate_series(1, 100) g;
+SELECT count(*) FROM gtt_colstats_ocd;
+ count 
+-------
+   100
+(1 row)
+
+COMMIT;
+DROP TABLE gtt_colstats_ocd;
+-- Column stats should not survive reconnect
+\c -
+INSERT INTO gtt_colstats SELECT g, 'cat_' || (g % 5), random() * 100 FROM generate_series(1, 100) g;
+-- Without ANALYZE in this session, no per-session column stats
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_colstats'::regclass;
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE gtt_colstats;
+--
+-- Per-session stats visibility via SRFs
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_srf_test (id int, category text, val float);
+INSERT INTO gtt_srf_test
+    SELECT g, 'cat_' || (g % 5), random() * 100
+    FROM generate_series(1, 1000) g;
+ANALYZE gtt_srf_test;
+-- pg_gtt_relstats should show relation-level stats
+SELECT table_name, relpages > 0 AS has_pages, reltuples > 0 AS has_tuples
+    FROM pg_gtt_relstats('gtt_srf_test'::regclass);
+  table_name  | has_pages | has_tuples 
+--------------+-----------+------------
+ gtt_srf_test | t         | t
+(1 row)
+
+-- pg_gtt_relstats with NULL shows all GTTs (just this one)
+SELECT table_name FROM pg_gtt_relstats() ORDER BY table_name;
+  table_name  
+--------------
+ gtt_srf_test
+(1 row)
+
+-- pg_gtt_colstats should show column-level stats
+SELECT attname, null_frac IS NOT NULL AS has_null_frac,
+       avg_width > 0 AS has_avg_width,
+       n_distinct != 0 AS has_n_distinct
+    FROM pg_gtt_colstats('gtt_srf_test'::regclass)
+    WHERE NOT inherited
+    ORDER BY attnum;
+ attname  | has_null_frac | has_avg_width | has_n_distinct 
+----------+---------------+---------------+----------------
+ id       | t             | t             | t
+ category | t             | t             | t
+ val      | t             | t             | t
+(3 rows)
+
+-- Category column should have MCV data
+SELECT attname, most_common_vals IS NOT NULL AS has_mcv,
+       most_common_freqs IS NOT NULL AS has_mcf,
+       histogram_bounds IS NOT NULL AS has_hist,
+       correlation IS NOT NULL AS has_corr
+    FROM pg_gtt_colstats('gtt_srf_test'::regclass)
+    WHERE attname = 'category' AND NOT inherited;
+ attname  | has_mcv | has_mcf | has_hist | has_corr 
+----------+---------+---------+----------+----------
+ category | t       | t       | f        | t
+(1 row)
+
+-- id column should have histogram bounds
+SELECT attname, histogram_bounds IS NOT NULL AS has_hist,
+       correlation IS NOT NULL AS has_corr
+    FROM pg_gtt_colstats('gtt_srf_test'::regclass)
+    WHERE attname = 'id' AND NOT inherited;
+ attname | has_hist | has_corr 
+---------+----------+----------
+ id      | t        | t
+(1 row)
+
+-- Stats should not be in pg_statistic
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_srf_test'::regclass;
+ count 
+-------
+     0
+(1 row)
+
+-- After TRUNCATE, relation-level stats are invalidated; column-level
+-- stats are retained, as pg_statistic is for regular tables
+TRUNCATE gtt_srf_test;
+SELECT count(*) FROM pg_gtt_relstats('gtt_srf_test'::regclass);
+ count 
+-------
+     0
+(1 row)
+
+SELECT count(*) FROM pg_gtt_colstats('gtt_srf_test'::regclass);
+ count 
+-------
+     3
+(1 row)
+
+-- Re-insert and re-analyze to get stats back
+INSERT INTO gtt_srf_test SELECT g, 'x', g FROM generate_series(1, 100) g;
+ANALYZE gtt_srf_test;
+SELECT table_name, reltuples FROM pg_gtt_relstats('gtt_srf_test'::regclass);
+  table_name  | reltuples 
+--------------+-----------
+ gtt_srf_test |       100
+(1 row)
+
+-- After reconnect, stats should be gone
+\c -
+SELECT count(*) FROM pg_gtt_relstats('gtt_srf_test'::regclass);
+ count 
+-------
+     0
+(1 row)
+
+SELECT count(*) FROM pg_gtt_colstats('gtt_srf_test'::regclass);
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE gtt_srf_test;
+--
+-- pg_gtt_clear_stats: discard per-session stats explicitly
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_clear_stats (id int, val text);
+INSERT INTO gtt_clear_stats SELECT g, 'v' || g FROM generate_series(1, 50) g;
+ANALYZE gtt_clear_stats;
+-- Stats are present
+SELECT count(*) FROM pg_gtt_relstats('gtt_clear_stats'::regclass);
+ count 
+-------
+     1
+(1 row)
+
+SELECT count(*) > 0 AS has_colstats FROM pg_gtt_colstats('gtt_clear_stats'::regclass);
+ has_colstats 
+--------------
+ t
+(1 row)
+
+-- Clear and verify both rel- and col-level stats are gone
+SELECT pg_gtt_clear_stats('gtt_clear_stats'::regclass);
+ pg_gtt_clear_stats 
+--------------------
+ 
+(1 row)
+
+SELECT count(*) FROM pg_gtt_relstats('gtt_clear_stats'::regclass);
+ count 
+-------
+     0
+(1 row)
+
+SELECT count(*) FROM pg_gtt_colstats('gtt_clear_stats'::regclass);
+ count 
+-------
+     0
+(1 row)
+
+-- A subsequent ANALYZE repopulates them
+ANALYZE gtt_clear_stats;
+SELECT count(*) FROM pg_gtt_relstats('gtt_clear_stats'::regclass);
+ count 
+-------
+     1
+(1 row)
+
+-- NULL clears stats for every GTT this session has touched
+CREATE GLOBAL TEMPORARY TABLE gtt_clear_stats2 (a int);
+INSERT INTO gtt_clear_stats2 SELECT generate_series(1, 20);
+ANALYZE gtt_clear_stats2;
+SELECT count(*) FROM pg_gtt_relstats();
+ count 
+-------
+     2
+(1 row)
+
+SELECT pg_gtt_clear_stats(NULL);
+ pg_gtt_clear_stats 
+--------------------
+ 
+(1 row)
+
+SELECT count(*) FROM pg_gtt_relstats();
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE gtt_clear_stats, gtt_clear_stats2;
+--
+-- pg_restore_relation_stats / pg_restore_attribute_stats reject GTTs
+--
+-- The shared pg_class and pg_statistic rows for a GTT are never read by
+-- the planner: GttGetSessionStats() / SearchStats() return
+-- session-private values from the per-session hash, falling back on the
+-- syscache only when the current session has not run ANALYZE.  Allowing
+-- pg_restore_*_stats to write the shared values would surface them to
+-- exactly that fallback path, undermining the per-session isolation.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_restore_stats (a int, b text);
+SELECT pg_restore_relation_stats(
+    'schemaname', 'public',
+    'relname', 'gtt_restore_stats',
+    'relpages', 42::integer,
+    'reltuples', 1000::real);
+ERROR:  cannot modify shared statistics for global temporary table "gtt_restore_stats"
+HINT:  Run ANALYZE in each session that uses the table.
+SELECT pg_clear_relation_stats('public', 'gtt_restore_stats');
+ERROR:  cannot modify shared statistics for global temporary table "gtt_restore_stats"
+HINT:  Run ANALYZE in each session that uses the table.
+SELECT pg_restore_attribute_stats(
+    'schemaname', 'public',
+    'relname', 'gtt_restore_stats',
+    'attname', 'a',
+    'inherited', false,
+    'null_frac', 0.0::real,
+    'avg_width', 4::integer,
+    'n_distinct', -1.0::real);
+ERROR:  cannot modify shared statistics for global temporary table "gtt_restore_stats"
+HINT:  Run ANALYZE in each session that uses the table.
+SELECT pg_clear_attribute_stats('public', 'gtt_restore_stats', 'a', false);
+ERROR:  cannot modify shared statistics for global temporary table "gtt_restore_stats"
+HINT:  Run ANALYZE in each session that uses the table.
+DROP TABLE gtt_restore_stats;
+--
+-- Subtransaction abort after first access to a GTT
+-- The per-session storage file is unlinked by PendingRelDelete when the
+-- subxact aborts; the hash entry must be reset so a subsequent access
+-- in the outer transaction re-creates the storage, not error out.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact (x int);
+BEGIN;
+SAVEPOINT s;
+INSERT INTO gtt_subxact VALUES (1);  -- first access, creates storage
+ROLLBACK TO SAVEPOINT s;             -- storage unlinked, hash entry cleaned
+-- outer xact continues: next access must re-create storage cleanly
+INSERT INTO gtt_subxact VALUES (2);
+SELECT * FROM gtt_subxact;
+ x 
+---
+ 2
+(1 row)
+
+COMMIT;
+SELECT * FROM gtt_subxact;
+ x 
+---
+ 2
+(1 row)
+
+DROP TABLE gtt_subxact;
+--
+-- DROP then recreate a same-named GTT in the same session
+-- Exercises the commit-side cleanup in gtt_xact_callback: the hash
+-- entry from the dropped table must be gone so the new CREATE uses
+-- fresh per-session state.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_recreate (x int);
+INSERT INTO gtt_recreate VALUES (1);
+DROP TABLE gtt_recreate;
+CREATE GLOBAL TEMPORARY TABLE gtt_recreate (y text);
+INSERT INTO gtt_recreate VALUES ('fresh');
+SELECT * FROM gtt_recreate;
+   y   
+-------
+ fresh
+(1 row)
+
+DROP TABLE gtt_recreate;
+--
+-- ALTER TABLE SET TABLESPACE on a GTT is rejected
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_notbs (x int);
+ALTER TABLE gtt_notbs SET TABLESPACE pg_default;  -- ERROR
+ERROR:  cannot change tablespace of global temporary table "gtt_notbs"
+DROP TABLE gtt_notbs;
+--
+-- ALTER TABLE INHERIT / NO INHERIT with GTT persistence mixing
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_parent2 (x int);
+CREATE TABLE perm_child2 (x int);
+ALTER TABLE perm_child2 INHERIT gtt_parent2;         -- ERROR (permanent into GTT)
+ERROR:  cannot inherit from global temporary relation "gtt_parent2"
+CREATE TEMPORARY TABLE temp_child2 (x int);
+ALTER TABLE temp_child2 INHERIT gtt_parent2;         -- ERROR (local temp into GTT)
+ERROR:  cannot inherit from global temporary relation "gtt_parent2"
+CREATE GLOBAL TEMPORARY TABLE gtt_child2 (x int);
+ALTER TABLE gtt_child2 INHERIT gtt_parent2;          -- OK: GTT→GTT
+ALTER TABLE gtt_child2 NO INHERIT gtt_parent2;
+DROP TABLE gtt_child2, gtt_parent2, perm_child2, temp_child2;
+--
+-- ATTACH PARTITION with GTT persistence mixing
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_part (x int) PARTITION BY RANGE (x);
+CREATE TABLE perm_part (x int);
+ALTER TABLE gtt_part ATTACH PARTITION perm_part
+    FOR VALUES FROM (0) TO (10);                     -- ERROR
+ERROR:  cannot attach a permanent relation as partition of temporary relation "gtt_part"
+CREATE TEMPORARY TABLE temp_part (x int);
+ALTER TABLE gtt_part ATTACH PARTITION temp_part
+    FOR VALUES FROM (0) TO (10);                     -- ERROR
+ERROR:  cannot attach a local temporary relation as partition of global temporary relation "gtt_part"
+CREATE GLOBAL TEMPORARY TABLE gtt_part_child (x int);
+ALTER TABLE gtt_part ATTACH PARTITION gtt_part_child
+    FOR VALUES FROM (0) TO (10);                     -- OK
+DROP TABLE gtt_part, perm_part, temp_part;
+--
+-- CREATE TABLE ... PARTITION OF persistence mixing with a GTT
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_prange (a int) PARTITION BY RANGE (a);
+-- a permanent partition of a GTT parent is rejected
+CREATE TABLE gtt_prange_perm PARTITION OF gtt_prange
+    FOR VALUES FROM (0) TO (10);                     -- ERROR
+ERROR:  cannot create a permanent relation as partition of temporary relation "gtt_prange"
+-- a local temporary partition of a GTT parent is rejected
+CREATE TEMPORARY TABLE gtt_prange_tmp PARTITION OF gtt_prange
+    FOR VALUES FROM (0) TO (10);                     -- ERROR: cannot mix
+ERROR:  cannot mix global temporary and local temporary tables in inheritance
+-- a GTT partition of a permanent parent is rejected
+CREATE TABLE perm_prange (a int) PARTITION BY RANGE (a);
+CREATE GLOBAL TEMPORARY TABLE perm_prange_gtt PARTITION OF perm_prange
+    FOR VALUES FROM (0) TO (10);                     -- ERROR
+ERROR:  cannot create a temporary relation as partition of permanent relation "perm_prange"
+DROP TABLE perm_prange;
+-- a GTT partition of a GTT parent is OK
+CREATE GLOBAL TEMPORARY TABLE gtt_prange_1 PARTITION OF gtt_prange
+    FOR VALUES FROM (0) TO (10);                     -- OK
+--
+-- SPLIT/MERGE PARTITION is not supported for global temporary tables: the new
+-- partition would not inherit the parent's persistence and would wrongly get
+-- permanent, cluster-wide storage under a per-session parent.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_prange_2 PARTITION OF gtt_prange
+    FOR VALUES FROM (10) TO (20);
+ALTER TABLE gtt_prange SPLIT PARTITION gtt_prange_2 INTO
+    (PARTITION gtt_prange_2a FOR VALUES FROM (10) TO (15),
+     PARTITION gtt_prange_2b FOR VALUES FROM (15) TO (20));  -- ERROR
+ERROR:  cannot split or merge partitions of global temporary table "gtt_prange"
+ALTER TABLE gtt_prange MERGE PARTITIONS (gtt_prange_1, gtt_prange_2)
+    INTO gtt_prange_m;                               -- ERROR
+ERROR:  cannot split or merge partitions of global temporary table "gtt_prange"
+DROP TABLE gtt_prange;
+--
+-- Property graphs and GTTs
+--
+CREATE TABLE gtt_pg_perm_v (id int PRIMARY KEY);
+CREATE GLOBAL TEMPORARY TABLE gtt_pg_gtt_v (id int PRIMARY KEY);
+-- A property graph itself cannot be global temporary (it has no storage).
+CREATE GLOBAL TEMPORARY PROPERTY GRAPH gtt_pg_self
+    VERTEX TABLES (gtt_pg_perm_v KEY (id));           -- ERROR
+ERROR:  property graphs cannot be global temporary because they do not have storage
+-- But a GTT may serve as an element table of a permanent property graph,
+-- because the GTT's definition is permanent; the graph stays permanent and a
+-- dependency on the GTT is recorded.
+CREATE PROPERTY GRAPH gtt_pg
+    VERTEX TABLES (gtt_pg_perm_v KEY (id), gtt_pg_gtt_v KEY (id));
+SELECT relpersistence FROM pg_class WHERE relname = 'gtt_pg';
+ relpersistence 
+----------------
+ p
+(1 row)
+
+DROP TABLE gtt_pg_gtt_v;                              -- ERROR: graph depends on it
+ERROR:  cannot drop table gtt_pg_gtt_v because other objects depend on it
+DETAIL:  vertex gtt_pg_gtt_v of property graph gtt_pg depends on table gtt_pg_gtt_v
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP PROPERTY GRAPH gtt_pg;
+DROP TABLE gtt_pg_perm_v, gtt_pg_gtt_v;
+--
+-- on_commit_delete reloption is internal: user cannot set it directly
+--
+CREATE TABLE perm_ocd (x int) WITH (on_commit_delete = true);  -- ERROR
+ERROR:  on_commit_delete is an internal reloption and cannot be set directly
+HINT:  Use ON COMMIT DELETE ROWS when creating a global temporary table.
+CREATE GLOBAL TEMPORARY TABLE gtt_ocd (x int)
+  WITH (on_commit_delete = true);                    -- ERROR: internal reloption
+ERROR:  on_commit_delete is an internal reloption and cannot be set directly
+HINT:  Use ON COMMIT DELETE ROWS when creating a global temporary table.
+CREATE GLOBAL TEMPORARY TABLE gtt_ocd (x int) ON COMMIT DELETE ROWS;
+ALTER TABLE gtt_ocd SET (on_commit_delete = false);  -- ERROR
+ERROR:  on_commit_delete is an internal reloption and cannot be set directly
+HINT:  Use ON COMMIT DELETE ROWS when creating a global temporary table.
+ALTER TABLE gtt_ocd RESET (on_commit_delete);        -- ERROR
+ERROR:  on_commit_delete is an internal reloption and cannot be set directly
+HINT:  Use ON COMMIT DELETE ROWS when creating a global temporary table.
+DROP TABLE gtt_ocd;
+--
+-- pg_relation_filepath on a GTT returns NULL before first access,
+-- and a session-local path after.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_fp (x int);
+-- Run from a fresh session so the table has no per-session storage
+\c -
+-- pg_relation_filepath/pg_relation_size read session-local GTT storage state
+-- that a parallel worker cannot see, so they must run in the leader; force
+-- that here so the checks are stable under debug_parallel_query.
+SET debug_parallel_query = off;
+SELECT pg_relation_filepath('gtt_fp') IS NULL AS no_storage_yet;
+ no_storage_yet 
+----------------
+ t
+(1 row)
+
+INSERT INTO gtt_fp VALUES (1);
+-- After first access, the path is the session-local file name (t<proc>_<rel>)
+SELECT pg_relation_filepath('gtt_fp') ~ '/t[0-9]+_[0-9]+$' AS got_session_path;
+ got_session_path 
+------------------
+ t
+(1 row)
+
+RESET debug_parallel_query;
+DROP TABLE gtt_fp;
+--
+-- pg_gtt_relstats / pg_gtt_colstats honour SELECT privilege
+--
+CREATE ROLE regress_gtt_acl_role;
+CREATE GLOBAL TEMPORARY TABLE gtt_acl (a int, b text);
+INSERT INTO gtt_acl
+SELECT i, repeat('x', i % 10)
+FROM generate_series(1, 100) i;
+ANALYZE gtt_acl;
+-- Owner sees stats
+SELECT count(*) > 0 FROM pg_gtt_relstats('gtt_acl'::regclass);
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT count(*) > 0 FROM pg_gtt_colstats('gtt_acl'::regclass);
+ ?column? 
+----------
+ t
+(1 row)
+
+-- Unprivileged role sees nothing
+SET ROLE regress_gtt_acl_role;
+SELECT count(*) FROM pg_gtt_relstats('gtt_acl'::regclass);
+ count 
+-------
+     0
+(1 row)
+
+SELECT count(*) FROM pg_gtt_colstats('gtt_acl'::regclass);
+ count 
+-------
+     0
+(1 row)
+
+RESET ROLE;
+-- Grant table-level SELECT: both SRFs become visible
+GRANT SELECT ON gtt_acl TO regress_gtt_acl_role;
+SET ROLE regress_gtt_acl_role;
+SELECT count(*) > 0 FROM pg_gtt_relstats('gtt_acl'::regclass);
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT count(*) > 0 FROM pg_gtt_colstats('gtt_acl'::regclass);
+ ?column? 
+----------
+ t
+(1 row)
+
+RESET ROLE;
+REVOKE SELECT ON gtt_acl FROM regress_gtt_acl_role;
+-- Grant column-level SELECT on just one column: colstats filters per-column
+GRANT SELECT (a) ON gtt_acl TO regress_gtt_acl_role;
+SET ROLE regress_gtt_acl_role;
+SELECT attname FROM pg_gtt_colstats('gtt_acl'::regclass) ORDER BY attname;
+ attname 
+---------
+ a
+(1 row)
+
+RESET ROLE;
+DROP TABLE gtt_acl;
+DROP ROLE regress_gtt_acl_role;
+--
+-- ON COMMIT DELETE ROWS with FK between two GTTs
+-- PreCommit_gtt_on_commit must batch the truncation so
+-- heap_truncate_check_FKs validates FK integrity across the set,
+-- matching regular-temp ON COMMIT DELETE ROWS behaviour.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_ocdr_pk (id int PRIMARY KEY)
+    ON COMMIT DELETE ROWS;
+CREATE GLOBAL TEMPORARY TABLE gtt_ocdr_fk (id int REFERENCES gtt_ocdr_pk(id))
+    ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_ocdr_pk VALUES (1), (2);
+INSERT INTO gtt_ocdr_fk VALUES (1);
+COMMIT;
+-- Both should be empty after commit; neither should have errored
+-- during commit's truncation
+SELECT count(*) FROM gtt_ocdr_pk;
+ count 
+-------
+     0
+(1 row)
+
+SELECT count(*) FROM gtt_ocdr_fk;
+ count 
+-------
+     0
+(1 row)
+
+-- Next transaction works cleanly
+BEGIN;
+INSERT INTO gtt_ocdr_pk VALUES (10);
+INSERT INTO gtt_ocdr_fk VALUES (10);
+SELECT count(*) FROM gtt_ocdr_pk;
+ count 
+-------
+     1
+(1 row)
+
+SELECT count(*) FROM gtt_ocdr_fk;
+ count 
+-------
+     1
+(1 row)
+
+COMMIT;
+DROP TABLE gtt_ocdr_fk;
+DROP TABLE gtt_ocdr_pk;
+--
+-- ALTER TABLE operations that would rewrite the heap are rejected,
+-- because rewriting rotates the catalog relfilenode that every
+-- session's per-session storage is keyed by.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_norewrite (a int, b varchar(10));
+INSERT INTO gtt_norewrite VALUES (1, 'hi');
+-- Type change that forces a rewrite: ERROR
+ALTER TABLE gtt_norewrite ALTER COLUMN a TYPE bigint;  -- ERROR
+ERROR:  cannot rewrite global temporary table "gtt_norewrite"
+-- Binary-coercible varchar length change (no rewrite): OK
+ALTER TABLE gtt_norewrite ALTER COLUMN b TYPE varchar(20);
+SELECT * FROM gtt_norewrite;
+ a | b  
+---+----
+ 1 | hi
+(1 row)
+
+-- ADD COLUMN with volatile default (forces rewrite): ERROR
+ALTER TABLE gtt_norewrite ADD COLUMN c int DEFAULT random()::int;  -- ERROR
+ERROR:  cannot rewrite global temporary table "gtt_norewrite"
+-- ADD COLUMN with constant default (no rewrite on modern PG): OK
+ALTER TABLE gtt_norewrite ADD COLUMN d int DEFAULT 42;
+SELECT * FROM gtt_norewrite;
+ a | b  | d  
+---+----+----
+ 1 | hi | 42
+(1 row)
+
+DROP TABLE gtt_norewrite;
+--
+-- CREATE TABLE AS and SELECT INTO create GTTs correctly
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_cta AS
+    SELECT i AS x, 'row_' || i::text AS y FROM generate_series(1, 10) i;
+SELECT relpersistence FROM pg_class WHERE relname = 'gtt_cta';
+ relpersistence 
+----------------
+ g
+(1 row)
+
+SELECT count(*) FROM gtt_cta;
+ count 
+-------
+    10
+(1 row)
+
+-- Data is per-session; reconnect sees no rows
+\c -
+SELECT count(*) FROM gtt_cta;
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE gtt_cta;
+-- SELECT INTO GLOBAL TEMPORARY should now produce a GTT, not a local TEMP
+SELECT i AS x INTO GLOBAL TEMPORARY gtt_si FROM generate_series(1, 5) i;
+SELECT relpersistence FROM pg_class WHERE relname = 'gtt_si';
+ relpersistence 
+----------------
+ g
+(1 row)
+
+SELECT count(*) FROM gtt_si;
+ count 
+-------
+     5
+(1 row)
+
+\c -
+SELECT count(*) FROM gtt_si;
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE gtt_si;
+--
+-- WITH HOLD cursor on an ON COMMIT DELETE ROWS GTT.
+-- PreCommit_Portals(false) materialises held portals before
+-- PreCommit_gtt_on_commit truncates per-session data, so the
+-- cursor's rows survive the commit.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_hold_ocdr (x int) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_hold_ocdr SELECT i FROM generate_series(1, 5) i;
+DECLARE gtt_hold_cur CURSOR WITH HOLD FOR
+    SELECT * FROM gtt_hold_ocdr ORDER BY x;
+COMMIT;
+-- Table was truncated at commit, but the held cursor materialised first
+SELECT count(*) FROM gtt_hold_ocdr;
+ count 
+-------
+     0
+(1 row)
+
+FETCH ALL FROM gtt_hold_cur;
+ x 
+---
+ 1
+ 2
+ 3
+ 4
+ 5
+(5 rows)
+
+CLOSE gtt_hold_cur;
+DROP TABLE gtt_hold_ocdr;
+--
+-- Subtransaction rollback
+--
+-- gtt_subxact_callback has to reconcile entries whose create/storage/index
+-- state was established inside an aborted savepoint.  Verify that data
+-- mutations in a rolled-back savepoint are not visible afterwards, and
+-- that the GTT remains usable on the next top-level transaction.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact (x int);
+BEGIN;
+INSERT INTO gtt_subxact VALUES (1);
+SAVEPOINT sp;
+INSERT INTO gtt_subxact VALUES (2), (3);
+SELECT count(*) FROM gtt_subxact;  -- 3
+ count 
+-------
+     3
+(1 row)
+
+ROLLBACK TO SAVEPOINT sp;
+SELECT count(*) FROM gtt_subxact;  -- 1
+ count 
+-------
+     1
+(1 row)
+
+COMMIT;
+SELECT count(*) FROM gtt_subxact;  -- 1
+ count 
+-------
+     1
+(1 row)
+
+-- Subxact that creates a GTT, then rolls back: the relation vanishes
+BEGIN;
+SAVEPOINT sp;
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact_new (y int);
+INSERT INTO gtt_subxact_new VALUES (42);
+ROLLBACK TO SAVEPOINT sp;
+SELECT 1 FROM pg_class WHERE relname = 'gtt_subxact_new';  -- 0 rows
+ ?column? 
+----------
+(0 rows)
+
+COMMIT;
+DROP TABLE gtt_subxact;
+--
+-- Self-drop: a session that has touched a GTT must be able to DROP it.
+-- GttCheckDroppable skips entries matching our own ProcNumber.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_selfdrop (x int);
+INSERT INTO gtt_selfdrop VALUES (1), (2);
+SELECT count(*) FROM gtt_selfdrop;
+ count 
+-------
+     2
+(1 row)
+
+DROP TABLE gtt_selfdrop;
+--
+-- ON COMMIT DELETE ROWS resets per-session statistics.
+-- PreCommit_gtt_on_commit calls GttResetSessionStats after truncating, so
+-- after commit the planner should see no per-session stats until the next
+-- ANALYZE.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_stats_ocdr (x int) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_stats_ocdr SELECT g FROM generate_series(1, 100) g;
+ANALYZE gtt_stats_ocdr;
+-- Stats are visible within the transaction
+SELECT table_name FROM pg_gtt_relstats()
+ WHERE table_name = 'gtt_stats_ocdr';
+   table_name   
+----------------
+ gtt_stats_ocdr
+(1 row)
+
+COMMIT;
+-- Commit truncated the data and cleared per-session stats
+SELECT table_name FROM pg_gtt_relstats()
+ WHERE table_name = 'gtt_stats_ocdr';
+ table_name 
+------------
+(0 rows)
+
+DROP TABLE gtt_stats_ocdr;
+--
+-- Subtransaction-abort counterpart of the gtt_abort_recreate test.  When
+-- the per-session entry is FIRST CREATED inside a subxact, ROLLBACK TO
+-- SAVEPOINT removes that entry and unlinks the file (PendingRelDelete is
+-- subxact-aware).  Without the relcache invalidation in
+-- gtt_subxact_callback, the outer xact's relcache entry would keep
+-- pointing at the now-deleted file.  Force a fresh session with \c -
+-- so this session has no hash entry going in; then the first INSERT in
+-- SAVEPOINT s1 hits the !found branch with create_subid = s1.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact_abort (x int) ON COMMIT DELETE ROWS;
+\c -
+BEGIN;
+SAVEPOINT s1;
+INSERT INTO gtt_subxact_abort VALUES (1);
+ROLLBACK TO SAVEPOINT s1;
+SELECT * FROM gtt_subxact_abort;        -- no error, 0 rows
+ x 
+---
+(0 rows)
+
+INSERT INTO gtt_subxact_abort VALUES (99);
+SELECT * FROM gtt_subxact_abort;
+ x  
+----
+ 99
+(1 row)
+
+COMMIT;
+SELECT * FROM gtt_subxact_abort;        -- empty after ON COMMIT DELETE ROWS
+ x 
+---
+(0 rows)
+
+DROP TABLE gtt_subxact_abort;
+--
+-- Heap-only access method enforcement: a GTT may only use the heap table
+-- access method (its per-session storage and wraparound handling are
+-- heap-specific).  A non-heap access method is rejected at CREATE, whether
+-- requested with USING or inherited from default_table_access_method.  Use a
+-- second AM OID that reuses heap's handler to exercise the check without a
+-- separate AM implementation.
+--
+CREATE ACCESS METHOD gtt_fake_heap TYPE TABLE HANDLER heap_tableam_handler;
+CREATE GLOBAL TEMPORARY TABLE gtt_am_bad (a int) USING gtt_fake_heap;  -- error
+ERROR:  access method "gtt_fake_heap" is not supported for global temporary tables
+SET default_table_access_method = gtt_fake_heap;
+CREATE GLOBAL TEMPORARY TABLE gtt_am_bad (a int);                     -- error
+ERROR:  access method "gtt_fake_heap" is not supported for global temporary tables
+RESET default_table_access_method;
+CREATE GLOBAL TEMPORARY TABLE gtt_am_ok (a int) USING heap;           -- ok
+DROP TABLE gtt_am_ok;
+CREATE TABLE gtt_am_reg (a int) USING gtt_fake_heap;                  -- ok (not a GTT)
+DROP TABLE gtt_am_reg;
+DROP ACCESS METHOD gtt_fake_heap;
+--
+-- global_temp_xid_warn_margin GUC: controls the head room before the
+-- transaction-ID horizon at which a warning is issued for aging GTT data.
+-- The hard error is fixed at the horizon and is exercised by a TAP test
+-- (the warning/error cannot be triggered deterministically here).
+--
+SHOW global_temp_xid_warn_margin;                 -- default 100000000
+ global_temp_xid_warn_margin 
+-----------------------------
+ 100000000
+(1 row)
+
+SET global_temp_xid_warn_margin = 0;              -- disables the warning
+SHOW global_temp_xid_warn_margin;
+ global_temp_xid_warn_margin 
+-----------------------------
+ 0
+(1 row)
+
+SET global_temp_xid_warn_margin = 250000000;
+SHOW global_temp_xid_warn_margin;
+ global_temp_xid_warn_margin 
+-----------------------------
+ 250000000
+(1 row)
+
+SET global_temp_xid_warn_margin = -1;             -- error: below minimum
+ERROR:  -1 is outside the valid range for parameter "global_temp_xid_warn_margin" (0 .. 2000000000)
+SET global_temp_xid_warn_margin = 3000000000;     -- error: above maximum
+ERROR:  invalid value for parameter "global_temp_xid_warn_margin": "3000000000"
+HINT:  Value exceeds integer range.
+RESET global_temp_xid_warn_margin;
+--
+-- TRUNCATE of a GTT is transaction-safe: this session's storage is swapped
+-- for new, empty files (the shared catalog relfilenode never changes), so
+-- ROLLBACK restores the rows, including across savepoints, and indexes
+-- remain consistent afterwards.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_trunc_xact (id int PRIMARY KEY, t text);
+INSERT INTO gtt_trunc_xact SELECT g, 'x' || g FROM generate_series(1, 100) g;
+-- catalog relfilenode is stable across TRUNCATE (it equals the OID at
+-- creation, and must still do so afterwards)
+SELECT relfilenode = oid AS filenode_premise
+  FROM pg_class WHERE relname = 'gtt_trunc_xact';
+ filenode_premise 
+------------------
+ t
+(1 row)
+
+BEGIN;
+TRUNCATE gtt_trunc_xact;
+SELECT count(*) FROM gtt_trunc_xact;              -- 0 inside the transaction
+ count 
+-------
+     0
+(1 row)
+
+ROLLBACK;
+SELECT count(*) FROM gtt_trunc_xact;              -- 100 again
+ count 
+-------
+   100
+(1 row)
+
+BEGIN;
+SAVEPOINT s1;
+TRUNCATE gtt_trunc_xact;
+ROLLBACK TO s1;
+SELECT count(*) FROM gtt_trunc_xact;              -- 100: subxact rollback
+ count 
+-------
+   100
+(1 row)
+
+SAVEPOINT s2;
+TRUNCATE gtt_trunc_xact;
+RELEASE s2;
+COMMIT;
+SELECT count(*) FROM gtt_trunc_xact;              -- 0: released swap commits
+ count 
+-------
+     0
+(1 row)
+
+-- index is rebuilt correctly after a rolled-back truncate
+INSERT INTO gtt_trunc_xact SELECT g, 'y' || g FROM generate_series(1, 30) g;
+BEGIN;
+TRUNCATE gtt_trunc_xact;
+INSERT INTO gtt_trunc_xact VALUES (7, 'seven');
+ROLLBACK;
+SET enable_seqscan = off;
+SELECT t FROM gtt_trunc_xact WHERE id = 13;       -- index scan finds old row
+  t  
+-----
+ y13
+(1 row)
+
+RESET enable_seqscan;
+SELECT relfilenode = oid AS filenode_unchanged
+  FROM pg_class WHERE relname = 'gtt_trunc_xact';
+ filenode_unchanged 
+--------------------
+ t
+(1 row)
+
+DROP TABLE gtt_trunc_xact;
+--
+-- Sequence RESTART paths swap only the session-local storage as well.
+--
+CREATE GLOBAL TEMPORARY SEQUENCE gtt_seq_restart;
+SELECT nextval('gtt_seq_restart'), nextval('gtt_seq_restart');
+ nextval | nextval 
+---------+---------
+       1 |       2
+(1 row)
+
+ALTER SEQUENCE gtt_seq_restart RESTART;
+SELECT nextval('gtt_seq_restart');                -- 1 again
+ nextval 
+---------
+       1
+(1 row)
+
+SELECT relfilenode = oid AS filenode_unchanged
+  FROM pg_class WHERE relname = 'gtt_seq_restart';
+ filenode_unchanged 
+--------------------
+ t
+(1 row)
+
+DROP SEQUENCE gtt_seq_restart;
+-- TRUNCATE ... RESTART IDENTITY, including rollback
+CREATE GLOBAL TEMPORARY TABLE gtt_ident (id int GENERATED ALWAYS AS IDENTITY, v text);
+INSERT INTO gtt_ident (v) VALUES ('a'), ('b'), ('c');
+TRUNCATE gtt_ident RESTART IDENTITY;
+INSERT INTO gtt_ident (v) VALUES ('d');
+SELECT id, v FROM gtt_ident;                      -- id restarts at 1
+ id | v 
+----+---
+  1 | d
+(1 row)
+
+BEGIN;
+TRUNCATE gtt_ident RESTART IDENTITY;
+ROLLBACK;
+INSERT INTO gtt_ident (v) VALUES ('e');
+SELECT count(*), max(id) FROM gtt_ident;          -- row back, id continues
+ count | max 
+-------+-----
+     2 |   2
+(1 row)
+
+DROP TABLE gtt_ident;
+--
+-- A GTT sequence presents its one mandatory row to any session, even via a
+-- direct scan that bypasses the sequence functions (as psql's \d does).
+--
+CREATE GLOBAL TEMPORARY SEQUENCE gtt_seq_scan START 42;
+SELECT last_value, is_called FROM gtt_seq_scan;   -- creator's view
+ last_value | is_called 
+------------+-----------
+         42 | f
+(1 row)
+
+\c -
+SELECT last_value, is_called FROM gtt_seq_scan;   -- fresh session: same seed
+ last_value | is_called 
+------------+-----------
+         42 | f
+(1 row)
+
+SELECT nextval('gtt_seq_scan');
+ nextval 
+---------
+      42
+(1 row)
+
+DROP SEQUENCE gtt_seq_scan;
+--
+-- Relations without storage cannot be global temporary.
+--
+CREATE GLOBAL TEMPORARY VIEW gtt_view_bad AS SELECT 1;          -- error
+ERROR:  views cannot be global temporary because they do not have storage
+CREATE OR REPLACE GLOBAL TEMPORARY VIEW gtt_view_bad AS SELECT 1;  -- error
+ERROR:  views cannot be global temporary because they do not have storage
+CREATE GLOBAL TEMPORARY RECURSIVE VIEW gtt_view_bad (n) AS SELECT 1;  -- error
+ERROR:  views cannot be global temporary because they do not have storage
+--
+-- ON COMMIT DELETE ROWS reclaims TOAST storage along with the heap.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_toast_ocdr (id int, blob text)
+  ON COMMIT DELETE ROWS;
+-- pg_relation_size reads session-local GTT storage; keep it in the leader.
+SET debug_parallel_query = off;
+BEGIN;
+INSERT INTO gtt_toast_ocdr
+  SELECT g, string_agg(md5(g::text || i::text), '')
+  FROM generate_series(1, 5) g, generate_series(1, 200) i GROUP BY g;
+SELECT pg_relation_size(reltoastrelid) > 0 AS toast_used_in_xact
+  FROM pg_class WHERE relname = 'gtt_toast_ocdr';
+ toast_used_in_xact 
+--------------------
+ t
+(1 row)
+
+COMMIT;
+SELECT pg_relation_size(reltoastrelid) AS toast_size_after_commit
+  FROM pg_class WHERE relname = 'gtt_toast_ocdr';
+ toast_size_after_commit 
+-------------------------
+                       0
+(1 row)
+
+RESET debug_parallel_query;
+SELECT count(*) FROM gtt_toast_ocdr;
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE gtt_toast_ocdr;
+--
+-- Materialized views must not capture session-private GTT data into a
+-- permanent relation: rejected both for direct references (at creation)
+-- and for references through a view (at population time).
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_mv (x int);
+INSERT INTO gtt_mv VALUES (1), (2), (3);
+CREATE MATERIALIZED VIEW gtt_mv_direct AS SELECT x FROM gtt_mv;     -- error
+ERROR:  materialized views must not use temporary objects
+DETAIL:  This view depends on global temporary table "gtt_mv".
+CREATE MATERIALIZED VIEW gtt_mv_nodata AS SELECT x FROM gtt_mv
+  WITH NO DATA;                                                     -- error
+ERROR:  materialized views must not use temporary objects
+DETAIL:  This view depends on global temporary table "gtt_mv".
+-- a plain view over a GTT stays permanent and is fine
+CREATE VIEW gtt_mv_view AS SELECT x FROM gtt_mv;
+SELECT relpersistence FROM pg_class WHERE relname = 'gtt_mv_view';
+ relpersistence 
+----------------
+ p
+(1 row)
+
+SELECT count(*) FROM gtt_mv_view;
+ count 
+-------
+     3
+(1 row)
+
+-- ... but materializing through the view is caught at population time
+CREATE MATERIALIZED VIEW gtt_mv_indirect AS SELECT x FROM gtt_mv_view;  -- error
+ERROR:  materialized views must not use temporary objects
+DETAIL:  This view depends on global temporary table "gtt_mv".
+DROP VIEW gtt_mv_view;
+DROP TABLE gtt_mv;
+--
+-- Per-session statistics roll back with the transaction that wrote them.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_stats_abort (id int, cat text);
+BEGIN;
+INSERT INTO gtt_stats_abort SELECT g, 'c' || (g % 4) FROM generate_series(1, 10000) g;
+ANALYZE gtt_stats_abort;
+SELECT reltuples FROM pg_gtt_relstats('gtt_stats_abort'::regclass);
+ reltuples 
+-----------
+     10000
+(1 row)
+
+ROLLBACK;
+SELECT count(*) FROM gtt_stats_abort;
+ count 
+-------
+     0
+(1 row)
+
+SELECT count(*) FROM pg_gtt_relstats('gtt_stats_abort'::regclass);  -- 0: stats gone
+ count 
+-------
+     0
+(1 row)
+
+SELECT count(*) FROM pg_gtt_colstats('gtt_stats_abort'::regclass);  -- 0: colstats too
+ count 
+-------
+     0
+(1 row)
+
+-- subtransaction variant
+INSERT INTO gtt_stats_abort SELECT g, 'x' FROM generate_series(1, 100) g;
+BEGIN;
+SAVEPOINT s1;
+ANALYZE gtt_stats_abort;
+ROLLBACK TO s1;
+COMMIT;
+SELECT count(*) FROM pg_gtt_relstats('gtt_stats_abort'::regclass);  -- 0
+ count 
+-------
+     0
+(1 row)
+
+-- committed ANALYZE still sticks
+ANALYZE gtt_stats_abort;
+SELECT reltuples FROM pg_gtt_relstats('gtt_stats_abort'::regclass);
+ reltuples 
+-----------
+       100
+(1 row)
+
+DROP TABLE gtt_stats_abort;
+--
+-- VACUUM of a GTT whose toast table has no session data stays quiet about
+-- the toast relation (only the named relation is reported when skipped).
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_vac_toast (id int PRIMARY KEY, pad text);
+INSERT INTO gtt_vac_toast SELECT g, repeat('y', 100) FROM generate_series(1, 100) g;
+VACUUM gtt_vac_toast;       -- no INFO about pg_toast_NNN
+DROP TABLE gtt_vac_toast;
+--
+-- DISCARD TEMP / DISCARD ALL clear per-session GTT data (and reset GTT
+-- sequences), transactionally.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_disc (x int);
+CREATE GLOBAL TEMPORARY SEQUENCE gtt_disc_seq;
+INSERT INTO gtt_disc VALUES (1), (2), (3);
+SELECT nextval('gtt_disc_seq'), nextval('gtt_disc_seq');
+ nextval | nextval 
+---------+---------
+       1 |       2
+(1 row)
+
+DISCARD TEMP;
+SELECT count(*) AS rows_after_discard FROM gtt_disc;
+ rows_after_discard 
+--------------------
+                  0
+(1 row)
+
+SELECT nextval('gtt_disc_seq') AS seq_after_discard;      -- restarts at 1
+ seq_after_discard 
+-------------------
+                 1
+(1 row)
+
+-- inside a transaction block, DISCARD TEMP rolls back cleanly
+INSERT INTO gtt_disc VALUES (4), (5);
+BEGIN;
+DISCARD TEMP;
+SELECT count(*) FROM gtt_disc;                            -- 0 inside
+ count 
+-------
+     0
+(1 row)
+
+ROLLBACK;
+SELECT count(*) AS rows_restored FROM gtt_disc;           -- 2 again
+ rows_restored 
+---------------
+             2
+(1 row)
+
+DROP SEQUENCE gtt_disc_seq;
+DROP TABLE gtt_disc;
+--
+-- Lazy storage creation: a session that merely opens, plans, or reads a
+-- GTT holds no per-session file (and so does not block peer DDL); files
+-- appear at the first genuine data access.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_lazy (id int PRIMARY KEY, t text);
+INSERT INTO gtt_lazy VALUES (1, 'one');
+\c -
+-- The pg_relation_filepath/pg_relation_size probes below read session-local
+-- GTT storage that a parallel worker cannot see, so force leader execution.
+-- Each \c - starts a fresh session and resets this, so it is re-applied below.
+SET debug_parallel_query = off;
+-- reads and planning do not materialize
+SELECT count(*) FROM gtt_lazy;
+ count 
+-------
+     0
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM gtt_lazy WHERE id = 1;
+                 QUERY PLAN                 
+--------------------------------------------
+ Index Scan using gtt_lazy_pkey on gtt_lazy
+   Index Cond: (id = 1)
+(2 rows)
+
+SELECT pg_relation_filepath('gtt_lazy') IS NULL AS heap_unmaterialized,
+       pg_relation_filepath('gtt_lazy_pkey') IS NULL AS index_unmaterialized;
+ heap_unmaterialized | index_unmaterialized 
+---------------------+----------------------
+ t                   | t
+(1 row)
+
+SELECT pg_relation_size('gtt_lazy') AS size_unmaterialized;
+ size_unmaterialized 
+---------------------
+                   0
+(1 row)
+
+-- an index scan materializes (and builds) only the index, not the heap
+SET enable_seqscan = off;
+SELECT * FROM gtt_lazy WHERE id = 1;
+ id | t 
+----+---
+(0 rows)
+
+RESET enable_seqscan;
+SELECT pg_relation_filepath('gtt_lazy') IS NULL AS heap_still_unmaterialized,
+       pg_relation_filepath('gtt_lazy_pkey') IS NOT NULL AS index_materialized;
+ heap_still_unmaterialized | index_materialized 
+---------------------------+--------------------
+ t                         | t
+(1 row)
+
+-- maintenance on unmaterialized storage is a no-op
+TRUNCATE gtt_lazy;
+ANALYZE gtt_lazy;
+SELECT count(*) FROM pg_gtt_relstats('gtt_lazy'::regclass);
+ count 
+-------
+     1
+(1 row)
+
+SELECT pg_relation_filepath('gtt_lazy') IS NULL AS still_unmaterialized;
+ still_unmaterialized 
+----------------------
+ t
+(1 row)
+
+-- the first write materializes the heap and its indexes together
+INSERT INTO gtt_lazy VALUES (2, 'two');
+SELECT pg_relation_filepath('gtt_lazy') IS NOT NULL AS heap_materialized;
+ heap_materialized 
+-------------------
+ t
+(1 row)
+
+SET enable_seqscan = off;
+SELECT t FROM gtt_lazy WHERE id = 2;
+  t  
+-----
+ two
+(1 row)
+
+RESET enable_seqscan;
+-- rollback of the materializing transaction discards the storage again
+\c -
+SET debug_parallel_query = off;		-- GTT storage probes must run in the leader
+BEGIN;
+INSERT INTO gtt_lazy VALUES (3, 'three');
+SELECT pg_relation_filepath('gtt_lazy') IS NOT NULL AS materialized_in_xact;
+ materialized_in_xact 
+----------------------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_filepath('gtt_lazy') IS NULL AS dematerialized_after_abort;
+ dematerialized_after_abort 
+----------------------------
+ t
+(1 row)
+
+SELECT count(*) FROM gtt_lazy;
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE gtt_lazy;
+--
+-- Lazy creation round 2: indexes are exactly as lazy as their heap, and
+-- a rollback that dematerializes the heap leaves no stale index content.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_lz2 (id int PRIMARY KEY);
+-- bare CREATE materializes nothing (and so blocks no peer DDL)
+SELECT pg_relation_filepath('gtt_lz2') IS NULL AS heap_unmat,
+       pg_relation_filepath('gtt_lz2_pkey') IS NULL AS pkey_unmat;
+ heap_unmat | pkey_unmat 
+------------+------------
+ t          | t
+(1 row)
+
+-- write + rollback returns to fully unmaterialized; no phantom index entries
+BEGIN;
+INSERT INTO gtt_lz2 SELECT generate_series(1, 5);
+ROLLBACK;
+SELECT pg_relation_filepath('gtt_lz2') IS NULL AS heap_unmat_after_abort,
+       pg_relation_filepath('gtt_lz2_pkey') IS NULL AS pkey_unmat_after_abort;
+ heap_unmat_after_abort | pkey_unmat_after_abort 
+------------------------+------------------------
+ t                      | t
+(1 row)
+
+INSERT INTO gtt_lz2 VALUES (1);                    -- no phantom duplicate
+SET enable_seqscan = off;
+SELECT * FROM gtt_lz2 WHERE id = 1;                -- index scan is consistent
+ id 
+----
+  1
+(1 row)
+
+RESET enable_seqscan;
+-- variant: index materialized by a scan in an earlier transaction, then a
+-- write is rolled back; the abort pass empties the stale index
+TRUNCATE gtt_lz2;
+DROP TABLE gtt_lz2;
+CREATE GLOBAL TEMPORARY TABLE gtt_lz3 (id int PRIMARY KEY);
+SET enable_seqscan = off;
+SELECT * FROM gtt_lz3 WHERE id = 9;                -- builds the (empty) index
+ id 
+----
+(0 rows)
+
+RESET enable_seqscan;
+SELECT pg_relation_filepath('gtt_lz3_pkey') IS NOT NULL AS pkey_mat_by_scan;
+ pkey_mat_by_scan 
+------------------
+ t
+(1 row)
+
+BEGIN;
+INSERT INTO gtt_lz3 SELECT generate_series(1, 5);
+ROLLBACK;
+INSERT INTO gtt_lz3 VALUES (2);
+SET enable_seqscan = off;
+SELECT * FROM gtt_lz3 WHERE id = 2;
+ id 
+----
+  2
+(1 row)
+
+RESET enable_seqscan;
+DROP TABLE gtt_lz3;
+--
+-- All index access methods behave on unmaterialized GTTs, including the
+-- AMs whose planner support reads index pages (SPGiST's amcanreturn).
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_ams (
+    id int,
+    arr int[],
+    p point,
+    rng int4range
+);
+CREATE INDEX gtt_ams_btree ON gtt_ams (id);
+CREATE INDEX gtt_ams_hash ON gtt_ams USING hash (id);
+CREATE INDEX gtt_ams_gin ON gtt_ams USING gin (arr);
+CREATE INDEX gtt_ams_gist ON gtt_ams USING gist (p);
+CREATE INDEX gtt_ams_spgist ON gtt_ams USING spgist (p);
+CREATE INDEX gtt_ams_brin ON gtt_ams USING brin (id);
+\c -
+SET debug_parallel_query = off;		-- GTT storage probes must run in the leader
+SELECT count(*) FROM gtt_ams;                      -- plans fine, reads nothing
+ count 
+-------
+     0
+(1 row)
+
+INSERT INTO gtt_ams VALUES (1, ARRAY[1,2], point(1,1), int4range(1,10));
+SET enable_seqscan = off;
+SELECT id FROM gtt_ams WHERE id = 1;
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM gtt_ams WHERE arr @> ARRAY[2];
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM gtt_ams WHERE p <@ box '((0,0),(2,2))';
+ id 
+----
+  1
+(1 row)
+
+RESET enable_seqscan;
+DROP TABLE gtt_ams;
+--
+-- DISCARD releases the storage and the DDL hold once it commits: after a
+-- committed DISCARD the session is back to the unmaterialized state.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_dd2 (x int);
+INSERT INTO gtt_dd2 VALUES (1), (2);
+DISCARD TEMP;
+SELECT pg_relation_filepath('gtt_dd2') IS NULL AS dematerialized;
+ dematerialized 
+----------------
+ t
+(1 row)
+
+SELECT count(*) FROM gtt_dd2;
+ count 
+-------
+     0
+(1 row)
+
+-- a rolled-back DISCARD keeps both the data and the storage
+INSERT INTO gtt_dd2 VALUES (3);
+BEGIN;
+DISCARD TEMP;
+ROLLBACK;
+SELECT count(*) AS rows_kept FROM gtt_dd2;
+ rows_kept 
+-----------
+         1
+(1 row)
+
+SELECT pg_relation_filepath('gtt_dd2') IS NOT NULL AS still_materialized;
+ still_materialized 
+--------------------
+ t
+(1 row)
+
+-- writing after DISCARD in the same transaction keeps the new data
+BEGIN;
+DISCARD TEMP;
+INSERT INTO gtt_dd2 VALUES (4);
+COMMIT;
+SELECT * FROM gtt_dd2;
+ x 
+---
+ 4
+(1 row)
+
+SELECT pg_relation_filepath('gtt_dd2') IS NOT NULL AS kept_for_new_data;
+ kept_for_new_data 
+-------------------
+ t
+(1 row)
+
+DROP TABLE gtt_dd2;
+RESET debug_parallel_query;
+--
+-- Index lifecycle corner cases found by randomized stress testing
+--
+-- 1. CREATE INDEX deferred (heap unmaterialized) in the same transaction
+--    that later materializes the heap: the deferred build must still happen.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc (id int PRIMARY KEY, v int);
+BEGIN;
+CREATE INDEX gtt_ilc_v_idx ON gtt_ilc (v);
+TRUNCATE gtt_ilc;
+INSERT INTO gtt_ilc VALUES (1, 1);
+COMMIT;
+SELECT * FROM gtt_ilc;
+ id | v 
+----+---
+  1 | 1
+(1 row)
+
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT * FROM gtt_ilc WHERE v = 1;
+ id | v 
+----+---
+  1 | 1
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE gtt_ilc;
+-- 2. CREATE INDEX built for real (heap materialized), then TRUNCATE swaps
+--    it to a fresh empty file in the same transaction: it must be rebuilt
+--    on next access, not assumed handled by index_create.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc2 (id int PRIMARY KEY, v int);
+INSERT INTO gtt_ilc2 VALUES (1, 1);
+BEGIN;
+CREATE INDEX gtt_ilc2_v_idx ON gtt_ilc2 (v);
+TRUNCATE gtt_ilc2;
+INSERT INTO gtt_ilc2 VALUES (2, 2);
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT * FROM gtt_ilc2 WHERE v = 2;
+ id | v 
+----+---
+  2 | 2
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE gtt_ilc2;
+-- 3. Planning a query when an index is materialized but emptied by the
+--    abort pass (heap storage reverted): plan-time metapage readers must
+--    treat it as unbuilt rather than read a zero-block file.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc3 (id int PRIMARY KEY, v int);
+BEGIN;
+INSERT INTO gtt_ilc3 VALUES (1, 1);
+TRUNCATE gtt_ilc3;
+ROLLBACK;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM gtt_ilc3 WHERE id BETWEEN 1 AND 10;  -- scan-builds pkey
+ count 
+-------
+     0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+BEGIN;
+INSERT INTO gtt_ilc3 VALUES (2, 2);
+SAVEPOINT s1;
+TRUNCATE gtt_ilc3;
+ROLLBACK;
+SELECT count(*) FROM gtt_ilc3;  -- planner must survive the emptied pkey
+ count 
+-------
+     0
+(1 row)
+
+INSERT INTO gtt_ilc3 VALUES (3, 3);
+SELECT * FROM gtt_ilc3;
+ id | v 
+----+---
+  3 | 3
+(1 row)
+
+DROP TABLE gtt_ilc3;
+-- 4. Nested aborts: a swap-undo from an outer subtransaction must not
+--    resurrect index_built over files the same abort unlinked.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc4 (id int PRIMARY KEY, v int);
+CREATE INDEX gtt_ilc4_v_idx ON gtt_ilc4 (v);
+BEGIN;
+SAVEPOINT s1;
+INSERT INTO gtt_ilc4 VALUES (1, 1);
+ROLLBACK TO SAVEPOINT s1;
+INSERT INTO gtt_ilc4 VALUES (2, 2);
+TRUNCATE gtt_ilc4;
+SAVEPOINT s2;
+INSERT INTO gtt_ilc4 VALUES (3, 3);
+ROLLBACK;
+BEGIN;
+SAVEPOINT s3;
+INSERT INTO gtt_ilc4 VALUES (4, 4);
+COMMIT;
+SELECT * FROM gtt_ilc4;
+ id | v 
+----+---
+  4 | 4
+(1 row)
+
+DROP TABLE gtt_ilc4;
+-- 5. DROP INDEX inside a rolled-back subtransaction must not retire the
+--    session entry at commit (the index is still live); a committed
+--    subtransaction drop must.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc5 (id int PRIMARY KEY, v int);
+INSERT INTO gtt_ilc5 VALUES (1, 1);
+BEGIN;
+CREATE INDEX gtt_ilc5_v_idx ON gtt_ilc5 (v);
+SAVEPOINT s1;
+DROP INDEX gtt_ilc5_v_idx;
+ROLLBACK TO SAVEPOINT s1;
+INSERT INTO gtt_ilc5 VALUES (2, 2);
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT * FROM gtt_ilc5 WHERE v = 2;
+ id | v 
+----+---
+  2 | 2
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+BEGIN;
+SAVEPOINT s2;
+DROP INDEX gtt_ilc5_v_idx;
+RELEASE SAVEPOINT s2;
+COMMIT;
+INSERT INTO gtt_ilc5 VALUES (3, 3);
+SELECT * FROM gtt_ilc5 ORDER BY id;
+ id | v 
+----+---
+  1 | 1
+  2 | 2
+  3 | 3
+(3 rows)
+
+DROP TABLE gtt_ilc5;
+-- 6. Sequence advancement is non-transactional and must survive the abort
+--    of the transaction that first materialized the per-session sequence;
+--    rolled-back nextval values are not handed out again.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc6
+  (id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, v int);
+BEGIN;
+INSERT INTO gtt_ilc6 (v) VALUES (1);
+ROLLBACK;
+INSERT INTO gtt_ilc6 (v) VALUES (2) RETURNING id;  -- id 2, not 1
+ id 
+----
+  2
+(1 row)
+
+-- TRUNCATE RESTART IDENTITY stays transactional
+BEGIN;
+TRUNCATE gtt_ilc6 RESTART IDENTITY;
+ROLLBACK;
+INSERT INTO gtt_ilc6 (v) VALUES (3) RETURNING id;  -- id 3: restart rolled back
+ id 
+----
+  3
+(1 row)
+
+TRUNCATE gtt_ilc6 RESTART IDENTITY;
+INSERT INTO gtt_ilc6 (v) VALUES (4) RETURNING id;  -- id 1: restart committed
+ id 
+----
+  1
+(1 row)
+
+-- an aborted CREATE still cleans up its sequence file
+BEGIN;
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc6b
+  (id int GENERATED BY DEFAULT AS IDENTITY, v int);
+INSERT INTO gtt_ilc6b (v) VALUES (1);
+ROLLBACK;
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc6b
+  (id int GENERATED BY DEFAULT AS IDENTITY, v int);
+INSERT INTO gtt_ilc6b (v) VALUES (1) RETURNING id;  -- id 1, fresh file
+ id 
+----
+  1
+(1 row)
+
+DROP TABLE gtt_ilc6;
+DROP TABLE gtt_ilc6b;
+-- 7. Repeated TRUNCATE in one transaction: the second TRUNCATE must not
+--    take the in-place path, which would touch the (possibly
+--    unmaterialized) toast relation's file directly.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc7 (id int PRIMARY KEY, t text);
+INSERT INTO gtt_ilc7 VALUES (1, 'x');
+BEGIN;
+TRUNCATE gtt_ilc7;
+TRUNCATE gtt_ilc7;
+INSERT INTO gtt_ilc7 VALUES (2, 'y');
+COMMIT;
+SELECT * FROM gtt_ilc7;
+ id | t 
+----+---
+  2 | y
+(1 row)
+
+-- and TRUNCATE of a same-transaction-created GTT (rd_createSubid path)
+BEGIN;
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc7b (id int PRIMARY KEY, t text);
+TRUNCATE gtt_ilc7b;
+INSERT INTO gtt_ilc7b VALUES (1, 'x');
+COMMIT;
+SELECT * FROM gtt_ilc7b;
+ id | t 
+----+---
+  1 | x
+(1 row)
+
+DROP TABLE gtt_ilc7;
+DROP TABLE gtt_ilc7b;
+-- 8. A same-transaction-created index whose storage is reverted by a
+--    subtransaction abort must still be rebuilt when the heap
+--    rematerializes later in the transaction.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc8 (id int PRIMARY KEY, v int);
+BEGIN;
+CREATE INDEX gtt_ilc8_v ON gtt_ilc8 (v);
+SAVEPOINT s1;
+INSERT INTO gtt_ilc8 VALUES (1, 1);
+ROLLBACK TO SAVEPOINT s1;
+INSERT INTO gtt_ilc8 VALUES (2, 2);
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT * FROM gtt_ilc8 WHERE v = 2;
+ id | v 
+----+---
+  2 | 2
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE gtt_ilc8;
+--
+-- Prepared statements with GTT
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_prep (a int, b text);
+INSERT INTO gtt_prep VALUES (1, 'one'), (2, 'two'), (3, 'three');
+PREPARE gtt_prep_q AS SELECT * FROM gtt_prep WHERE a = $1;
+EXECUTE gtt_prep_q(2);
+ a |  b  
+---+-----
+ 2 | two
+(1 row)
+
+EXECUTE gtt_prep_q(1);
+ a |  b  
+---+-----
+ 1 | one
+(1 row)
+
+DEALLOCATE gtt_prep_q;
+-- PREPARE/EXECUTE for CTAS
+PREPARE ctas_prep AS SELECT g, g * 2 AS doubled FROM generate_series(1, 3) g;
+CREATE GLOBAL TEMP TABLE gtt_ctas_exec AS EXECUTE ctas_prep;
+SELECT * FROM gtt_ctas_exec ORDER BY g;
+ g | doubled 
+---+---------
+ 1 |       2
+ 2 |       4
+ 3 |       6
+(3 rows)
+
+DROP TABLE gtt_ctas_exec;
+DEALLOCATE ctas_prep;
+DROP TABLE gtt_prep;
+--
+-- Typed GTT (OF composite type)
+--
+CREATE TYPE gtt_composite AS (a int, b int);
+CREATE GLOBAL TEMP TABLE gtt_typed OF gtt_composite;
+INSERT INTO gtt_typed VALUES (1, 2);
+SELECT * FROM gtt_typed;
+ a | b 
+---+---
+ 1 | 2
+(1 row)
+
+DROP TABLE gtt_typed;
+DROP TYPE gtt_composite;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47fb..9d786bf0f5e 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -117,6 +117,11 @@ test: json jsonb json_encoding jsonpath jsonpath_encoding jsonb_jsonpath sqljson
 # ----------
 test: plancache limit plpgsql copy2 temp domain rangefuncs prepare conversion truncate alter_table sequence polymorphism rowtypes returning largeobject with xml
 
+# ----------
+# global_temp does reconnects like temp, run it separately
+# ----------
+test: global_temp
+
 # ----------
 # Another group of parallel tests
 #
diff --git a/src/test/regress/sql/global_temp.sql b/src/test/regress/sql/global_temp.sql
new file mode 100644
index 00000000000..2db87ef39e0
--- /dev/null
+++ b/src/test/regress/sql/global_temp.sql
@@ -0,0 +1,1548 @@
+--
+-- Tests for global temporary tables
+--
+
+-- Basic creation and data isolation
+CREATE GLOBAL TEMPORARY TABLE gtt_basic (a int, b text);
+
+-- Verify it exists in pg_class with correct persistence
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_basic';
+
+-- Insert and query data
+INSERT INTO gtt_basic VALUES (1, 'hello'), (2, 'world');
+SELECT * FROM gtt_basic ORDER BY a;
+
+-- Verify local buffer usage (no WAL)
+SELECT count(*) > 0 AS has_data FROM gtt_basic;
+
+-- GLOBAL TEMP shorthand
+CREATE GLOBAL TEMP TABLE gtt_short (x int);
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_short';
+DROP TABLE gtt_short;
+
+-- Verify the table is visible after reconnect (definition persists)
+\c -
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_basic';
+-- But data should be gone (new session)
+SELECT * FROM gtt_basic ORDER BY a;
+
+-- Re-insert for further tests
+INSERT INTO gtt_basic VALUES (10, 'new session');
+SELECT * FROM gtt_basic ORDER BY a;
+
+--
+-- Indexes
+--
+CREATE INDEX gtt_basic_idx ON gtt_basic (a);
+
+-- Verify index is usable
+SELECT * FROM gtt_basic WHERE a = 10;
+
+-- Index is in catalog with correct persistence
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_basic_idx';
+
+-- Insert more and verify index scan still works
+INSERT INTO gtt_basic VALUES (20, 'indexed');
+SELECT * FROM gtt_basic WHERE a = 20;
+
+--
+-- ON COMMIT PRESERVE ROWS (default)
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_preserve (x int);
+BEGIN;
+INSERT INTO gtt_preserve VALUES (1), (2), (3);
+COMMIT;
+-- Data should survive commit
+SELECT * FROM gtt_preserve ORDER BY x;
+DROP TABLE gtt_preserve;
+
+-- Explicit ON COMMIT PRESERVE ROWS
+CREATE GLOBAL TEMPORARY TABLE gtt_preserve2 (x int) ON COMMIT PRESERVE ROWS;
+BEGIN;
+INSERT INTO gtt_preserve2 VALUES (1), (2), (3);
+COMMIT;
+SELECT * FROM gtt_preserve2 ORDER BY x;
+DROP TABLE gtt_preserve2;
+
+--
+-- ON COMMIT DELETE ROWS
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_delete (x int) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_delete VALUES (1), (2), (3);
+-- Data visible within transaction
+SELECT * FROM gtt_delete ORDER BY x;
+COMMIT;
+-- Data should be gone after commit
+SELECT * FROM gtt_delete ORDER BY x;
+
+-- Works across multiple transactions
+BEGIN;
+INSERT INTO gtt_delete VALUES (10);
+COMMIT;
+SELECT * FROM gtt_delete ORDER BY x;
+
+-- ON COMMIT DELETE ROWS with indexes
+CREATE INDEX gtt_delete_idx ON gtt_delete (x);
+BEGIN;
+INSERT INTO gtt_delete VALUES (100), (200);
+SELECT * FROM gtt_delete ORDER BY x;
+COMMIT;
+-- Data gone, index still works for next transaction
+SELECT * FROM gtt_delete ORDER BY x;
+BEGIN;
+INSERT INTO gtt_delete VALUES (300);
+SELECT * FROM gtt_delete WHERE x = 300;
+COMMIT;
+
+DROP TABLE gtt_delete;
+
+-- A first use that consists of INSERT followed by ROLLBACK must leave the
+-- GTT usable in subsequent transactions: the abort path that removes the
+-- per-session storage hash entry (and unlinks the file via PendingRelDelete)
+-- has to invalidate the relcache so the next access re-runs
+-- GttInitSessionStorage and re-creates the file.
+CREATE GLOBAL TEMPORARY TABLE gtt_abort_recreate (x int) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_abort_recreate VALUES (1);
+ROLLBACK;
+SELECT * FROM gtt_abort_recreate;
+BEGIN;
+INSERT INTO gtt_abort_recreate VALUES (99);
+SELECT * FROM gtt_abort_recreate;
+COMMIT;
+SELECT * FROM gtt_abort_recreate;  -- empty (ON COMMIT DELETE ROWS)
+DROP TABLE gtt_abort_recreate;
+
+--
+-- ON COMMIT DROP is not allowed for GTTs
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_nodrop (x int) ON COMMIT DROP;  -- ERROR
+
+--
+-- DDL restrictions
+--
+
+-- Cannot ALTER to LOGGED or UNLOGGED
+CREATE GLOBAL TEMPORARY TABLE gtt_noalter (x int);
+ALTER TABLE gtt_noalter SET LOGGED;      -- ERROR
+ALTER TABLE gtt_noalter SET UNLOGGED;    -- ERROR
+DROP TABLE gtt_noalter;
+
+-- CLUSTER, REPACK, and REINDEX are not supported (would change shared relfilenode)
+CREATE GLOBAL TEMPORARY TABLE gtt_nocluster (x int);
+CREATE INDEX gtt_nocluster_idx ON gtt_nocluster (x);
+CLUSTER gtt_nocluster USING gtt_nocluster_idx;  -- ERROR
+REPACK gtt_nocluster;                            -- ERROR
+REPACK (CONCURRENTLY) gtt_nocluster;             -- ERROR
+REINDEX TABLE gtt_nocluster;                     -- ERROR
+REINDEX INDEX gtt_nocluster_idx;                 -- ERROR
+REINDEX TABLE CONCURRENTLY gtt_nocluster;        -- NOTICE (falls back to non-concurrent), ERROR
+REINDEX INDEX CONCURRENTLY gtt_nocluster_idx;    -- NOTICE (falls back to non-concurrent), ERROR
+-- VACUUM FULL is rejected (it would reassign the shared relfilenode); plain
+-- VACUUM is the supported way to maintain a GTT (see below)
+VACUUM FULL gtt_nocluster;
+DROP TABLE gtt_nocluster;
+
+-- Database- and partitioned-wide bulk commands silently skip GTTs rather
+-- than aborting on the first one.  We can't exercise REPACK; (no target)
+-- here because it disallows running inside a transaction block, but we
+-- can verify the partitioned-GTT and REINDEX SCHEMA paths.
+CREATE SCHEMA gtt_bulk;
+CREATE GLOBAL TEMPORARY TABLE gtt_bulk.gtt_part (x int) PARTITION BY RANGE (x);
+CREATE GLOBAL TEMPORARY TABLE gtt_bulk.gtt_part_1 PARTITION OF gtt_bulk.gtt_part
+    FOR VALUES FROM (0) TO (100);
+REPACK gtt_bulk.gtt_part;                        -- ERROR (clean message at parent)
+REINDEX SCHEMA gtt_bulk;                         -- no error: GTTs silently skipped
+DROP SCHEMA gtt_bulk CASCADE;
+
+-- CREATE STATISTICS is not supported (would require per-session extended stats)
+CREATE GLOBAL TEMPORARY TABLE gtt_nostats (a int, b int);
+CREATE STATISTICS gtt_nostats_stats ON a, b FROM gtt_nostats;  -- ERROR
+DROP TABLE gtt_nostats;
+
+-- CREATE TABLE ... (LIKE ...): a GTT cannot carry extended statistics, so
+-- INCLUDING STATISTICS (and INCLUDING ALL) skips them with a warning rather
+-- than failing the command.
+CREATE TABLE gtt_like_src (a int, b int);
+CREATE STATISTICS gtt_like_src_stats ON a, b FROM gtt_like_src;
+CREATE GLOBAL TEMPORARY TABLE gtt_like_all (LIKE gtt_like_src INCLUDING ALL);  -- WARNING
+SELECT count(*) AS extstats_on_gtt
+    FROM pg_statistic_ext WHERE stxrelid = 'gtt_like_all'::regclass;
+-- a plain LIKE (no statistics requested) is unaffected
+CREATE GLOBAL TEMPORARY TABLE gtt_like_plain (LIKE gtt_like_src);
+DROP TABLE gtt_like_all, gtt_like_plain, gtt_like_src;
+
+-- CREATE INDEX CONCURRENTLY falls back to non-concurrent
+CREATE GLOBAL TEMPORARY TABLE gtt_cic (x int);
+INSERT INTO gtt_cic VALUES (1), (2), (3);
+CREATE INDEX CONCURRENTLY gtt_cic_idx ON gtt_cic (x);
+-- Verify it works
+SELECT * FROM gtt_cic WHERE x = 2;
+
+-- DROP INDEX CONCURRENTLY likewise falls back to non-concurrent: the
+-- multi-transaction protocol would mark the index invalid in the shared
+-- catalog while peers' per-session storage is unaffected.
+DROP INDEX CONCURRENTLY gtt_cic_idx;
+SELECT * FROM gtt_cic WHERE x = 2;
+DROP TABLE gtt_cic;
+
+-- VACUUM freezes a GTT's session-local data in place.  The new freeze
+-- horizon is kept per session; the shared pg_class row keeps invalid
+-- relfrozenxid/relminmxid (it cannot describe any one session's data).
+CREATE GLOBAL TEMPORARY TABLE gtt_vacuum (x int);
+-- Without session data there is nothing to freeze, so VACUUM is skipped.
+VACUUM gtt_vacuum;
+INSERT INTO gtt_vacuum VALUES (1), (2), (3);
+-- With data, VACUUM and VACUUM FREEZE run against the session-local storage.
+VACUUM gtt_vacuum;
+VACUUM (FREEZE) gtt_vacuum;
+-- Data still accessible after vacuuming/freezing.
+SELECT count(*) FROM gtt_vacuum;
+-- The shared catalog row is untouched: freeze state lives per session.
+SELECT relfrozenxid, relminmxid FROM pg_class WHERE relname = 'gtt_vacuum';
+-- VACUUM ANALYZE runs both the vacuum and the (session-local) analyze.
+VACUUM ANALYZE gtt_vacuum;
+SELECT count(*) FROM gtt_vacuum;
+DROP TABLE gtt_vacuum;
+
+-- FK: GTT can reference another GTT
+CREATE GLOBAL TEMPORARY TABLE gtt_pk (id int PRIMARY KEY);
+CREATE GLOBAL TEMPORARY TABLE gtt_fk (id int REFERENCES gtt_pk(id));
+DROP TABLE gtt_fk;
+DROP TABLE gtt_pk;
+
+-- FK: GTT cannot reference permanent table
+CREATE TABLE perm_pk (id int PRIMARY KEY);
+CREATE GLOBAL TEMPORARY TABLE gtt_fk_bad (id int REFERENCES perm_pk(id));  -- ERROR
+DROP TABLE perm_pk;
+
+-- FK: permanent table cannot reference GTT
+CREATE GLOBAL TEMPORARY TABLE gtt_pk2 (id int PRIMARY KEY);
+CREATE TABLE perm_fk_bad (id int REFERENCES gtt_pk2(id));  -- ERROR
+DROP TABLE gtt_pk2;
+
+-- Inheritance restrictions: cannot mix GTT and local temp
+CREATE GLOBAL TEMPORARY TABLE gtt_parent (x int);
+CREATE TEMPORARY TABLE local_child () INHERITS (gtt_parent);  -- ERROR
+CREATE TEMPORARY TABLE local_parent (x int);
+CREATE GLOBAL TEMPORARY TABLE gtt_child () INHERITS (local_parent);  -- ERROR
+DROP TABLE local_parent;
+-- Inheritance restrictions: cannot mix GTT and permanent
+CREATE TABLE perm_child () INHERITS (gtt_parent);  -- ERROR
+-- GTT can inherit from another GTT
+CREATE GLOBAL TEMPORARY TABLE gtt_child2 () INHERITS (gtt_parent);
+INSERT INTO gtt_child2 VALUES (42);
+SELECT * FROM gtt_parent;  -- should see child's data
+DROP TABLE gtt_child2;
+DROP TABLE gtt_parent;
+
+-- Views on GTTs see per-session data
+CREATE GLOBAL TEMPORARY TABLE gtt_viewtest (id int, val text);
+CREATE VIEW gtt_view AS SELECT * FROM gtt_viewtest;
+INSERT INTO gtt_viewtest VALUES (1, 'hello'), (2, 'world');
+SELECT * FROM gtt_view ORDER BY id;
+DROP VIEW gtt_view;
+DROP TABLE gtt_viewtest;
+
+-- Triggers on GTTs
+CREATE GLOBAL TEMPORARY TABLE gtt_trigger (id int, val text);
+CREATE FUNCTION gtt_trigger_fn() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+    NEW.val := NEW.val || ' (triggered)';
+    RETURN NEW;
+END;
+$$;
+CREATE TRIGGER gtt_trg BEFORE INSERT ON gtt_trigger
+    FOR EACH ROW EXECUTE FUNCTION gtt_trigger_fn();
+INSERT INTO gtt_trigger VALUES (1, 'test');
+SELECT * FROM gtt_trigger;
+DROP TABLE gtt_trigger;
+DROP FUNCTION gtt_trigger_fn();
+
+-- SERIAL (sequence) columns on GTTs: the backing sequence is itself a
+-- global temporary sequence so each session gets its own counter.
+CREATE GLOBAL TEMPORARY TABLE gtt_serial (id serial, val text);
+SELECT relname, relpersistence FROM pg_class
+    WHERE relname LIKE 'gtt_serial%' ORDER BY relname;
+INSERT INTO gtt_serial (val) VALUES ('a'), ('b'), ('c');
+SELECT * FROM gtt_serial ORDER BY id;
+-- currval/setval follow the per-session counter
+SELECT currval('gtt_serial_id_seq');
+SELECT setval('gtt_serial_id_seq', 100);
+INSERT INTO gtt_serial (val) VALUES ('d');
+SELECT * FROM gtt_serial ORDER BY id;
+DROP TABLE gtt_serial;
+
+-- DROP CASCADE with dependent view
+CREATE GLOBAL TEMPORARY TABLE gtt_deptest (x int);
+CREATE VIEW gtt_depview AS SELECT x FROM gtt_deptest;
+DROP TABLE gtt_deptest;  -- ERROR: view depends on it
+DROP TABLE gtt_deptest CASCADE;  -- OK: drops view too
+-- Verify the view is gone
+SELECT * FROM gtt_depview;  -- ERROR
+
+-- Cannot create GTT in temporary schema
+CREATE TEMPORARY TABLE force_temp_schema (x int);
+CREATE GLOBAL TEMPORARY TABLE pg_temp.gtt_in_temp (x int);  -- ERROR
+DROP TABLE force_temp_schema;
+
+--
+-- Toast tables
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_toast (id int, data text);
+INSERT INTO gtt_toast VALUES (1, repeat('x', 10000));
+SELECT id, length(data) FROM gtt_toast;
+DROP TABLE gtt_toast;
+
+--
+-- Multiple GTTs
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_multi1 (a int);
+CREATE GLOBAL TEMPORARY TABLE gtt_multi2 (b text);
+INSERT INTO gtt_multi1 VALUES (1), (2);
+INSERT INTO gtt_multi2 VALUES ('a'), ('b');
+SELECT * FROM gtt_multi1 ORDER BY a;
+SELECT * FROM gtt_multi2 ORDER BY b;
+DROP TABLE gtt_multi1;
+DROP TABLE gtt_multi2;
+
+--
+-- TRUNCATE
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_trunc (x int);
+INSERT INTO gtt_trunc VALUES (1), (2), (3);
+SELECT count(*) FROM gtt_trunc;
+TRUNCATE gtt_trunc;
+SELECT count(*) FROM gtt_trunc;
+-- Insert after truncate still works
+INSERT INTO gtt_trunc VALUES (10);
+SELECT * FROM gtt_trunc ORDER BY x;
+DROP TABLE gtt_trunc;
+
+-- TRUNCATE with indexes
+CREATE GLOBAL TEMPORARY TABLE gtt_trunc2 (x int);
+CREATE INDEX gtt_trunc2_idx ON gtt_trunc2 (x);
+INSERT INTO gtt_trunc2 VALUES (1), (2), (3);
+SELECT * FROM gtt_trunc2 ORDER BY x;
+TRUNCATE gtt_trunc2;
+SELECT * FROM gtt_trunc2 ORDER BY x;
+-- Index works after truncate
+INSERT INTO gtt_trunc2 VALUES (99);
+SELECT * FROM gtt_trunc2 WHERE x = 99;
+DROP TABLE gtt_trunc2;
+
+--
+-- COPY
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_copy (a int, b text);
+COPY gtt_copy FROM stdin;
+1	alpha
+2	beta
+3	gamma
+\.
+SELECT * FROM gtt_copy ORDER BY a;
+COPY gtt_copy TO stdout;
+DROP TABLE gtt_copy;
+
+--
+-- UPDATE and DELETE
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_dml (id int, val text);
+INSERT INTO gtt_dml VALUES (1, 'one'), (2, 'two'), (3, 'three');
+UPDATE gtt_dml SET val = 'TWO' WHERE id = 2;
+DELETE FROM gtt_dml WHERE id = 3;
+SELECT * FROM gtt_dml ORDER BY id;
+DROP TABLE gtt_dml;
+
+--
+-- Data survives subtransactions
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact (x int);
+BEGIN;
+INSERT INTO gtt_subxact VALUES (1);
+SAVEPOINT sp1;
+INSERT INTO gtt_subxact VALUES (2);
+ROLLBACK TO sp1;
+INSERT INTO gtt_subxact VALUES (3);
+COMMIT;
+SELECT * FROM gtt_subxact ORDER BY x;
+DROP TABLE gtt_subxact;
+
+--
+-- Verify data is session-private via reconnect
+--
+INSERT INTO gtt_basic VALUES (99, 'before reconnect');
+SELECT * FROM gtt_basic ORDER BY a;
+\c -
+-- New session: should not see previous session's data
+SELECT * FROM gtt_basic ORDER BY a;
+
+--
+-- Clean up
+--
+DROP TABLE gtt_basic;
+
+--
+-- GTT sequences: counter is per-session
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_seq_tbl (id serial, v text);
+INSERT INTO gtt_seq_tbl (v) VALUES ('a'), ('b'), ('c');
+SELECT * FROM gtt_seq_tbl ORDER BY id;
+\c -
+-- Fresh session: table empty, counter restarts at 1.
+SELECT * FROM gtt_seq_tbl ORDER BY id;
+INSERT INTO gtt_seq_tbl (v) VALUES ('x'), ('y');
+SELECT * FROM gtt_seq_tbl ORDER BY id;
+DROP TABLE gtt_seq_tbl;
+
+-- Standalone GLOBAL TEMPORARY SEQUENCE: definition persistent, counter per-session.
+CREATE GLOBAL TEMPORARY SEQUENCE gtt_seq START 10 INCREMENT 5;
+SELECT relname, relpersistence FROM pg_class WHERE relname = 'gtt_seq';
+SELECT nextval('gtt_seq'), nextval('gtt_seq'), nextval('gtt_seq');
+\c -
+-- Fresh session sees the definition and gets its own counter from START.
+SELECT nextval('gtt_seq'), nextval('gtt_seq');
+DROP SEQUENCE gtt_seq;
+
+--
+-- Row Level Security on GTTs
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_rls (id int, visible_to text);
+ALTER TABLE gtt_rls ENABLE ROW LEVEL SECURITY;
+CREATE ROLE regress_gtt_rls_user;
+GRANT SELECT, INSERT ON gtt_rls TO regress_gtt_rls_user;
+CREATE POLICY gtt_rls_policy ON gtt_rls
+    USING (visible_to = current_user);
+-- Insert rows: some for regress_gtt_rls_user, some not
+INSERT INTO gtt_rls VALUES (1, 'regress_gtt_rls_user'), (2, 'other_user'), (3, 'regress_gtt_rls_user');
+-- Table owner bypasses RLS by default
+SELECT * FROM gtt_rls ORDER BY id;
+SET ROLE regress_gtt_rls_user;
+-- Non-owner should only see rows matching the policy
+SELECT * FROM gtt_rls ORDER BY id;
+-- Non-owner insert should work
+INSERT INTO gtt_rls VALUES (4, 'regress_gtt_rls_user');
+-- Should see only matching rows
+SELECT * FROM gtt_rls ORDER BY id;
+RESET ROLE;
+-- Owner sees all rows again
+SELECT * FROM gtt_rls ORDER BY id;
+DROP TABLE gtt_rls;
+DROP ROLE regress_gtt_rls_user;
+
+-- A policy on a permanent table may reference a GTT: the GTT's definition is
+-- permanent, so the reference is always resolvable (unlike a local temporary
+-- table, whose definition exists only in its own session).  The subquery is
+-- evaluated against the current session's per-session data.
+CREATE TABLE gtt_rls_perm (a int);
+CREATE GLOBAL TEMPORARY TABLE gtt_rls_ref (a int);
+CREATE POLICY gtt_rls_perm_policy ON gtt_rls_perm AS RESTRICTIVE
+    USING ((SELECT a IS NOT NULL FROM gtt_rls_ref WHERE a = 1));
+ALTER TABLE gtt_rls_perm ENABLE ROW LEVEL SECURITY;
+INSERT INTO gtt_rls_perm VALUES (1);
+DROP TABLE gtt_rls_perm, gtt_rls_ref;
+
+--
+-- ANALYZE and per-session statistics
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_analyze (id int, val text);
+INSERT INTO gtt_analyze SELECT g, 'row ' || g FROM generate_series(1, 1000) g;
+
+-- Before ANALYZE: pg_class has default values
+SELECT relpages, reltuples FROM pg_class WHERE relname = 'gtt_analyze';
+
+-- Run ANALYZE
+ANALYZE gtt_analyze;
+
+-- pg_class should NOT be updated (stats are per-session, not shared)
+SELECT relpages, reltuples FROM pg_class WHERE relname = 'gtt_analyze';
+
+-- Data is queryable after ANALYZE
+SELECT count(*) FROM gtt_analyze WHERE id <= 500;
+
+-- ANALYZE with indexes
+CREATE INDEX gtt_analyze_idx ON gtt_analyze (id);
+ANALYZE gtt_analyze;
+SELECT count(*) FROM gtt_analyze WHERE id = 500;
+
+-- Column stats should NOT be written to shared pg_statistic
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_analyze'::regclass;
+
+-- Column stats should be used by planner (distinct count for id should be ~1000)
+-- Check that stadistinct is reflected in planner estimates
+EXPLAIN (COSTS OFF) SELECT * FROM gtt_analyze WHERE id = 42;
+
+-- TRUNCATE should reset per-session relation stats (column stats are
+-- retained, as pg_statistic is for regular tables)
+TRUNCATE gtt_analyze;
+-- After truncate and re-insert, stats should reflect new data after ANALYZE
+INSERT INTO gtt_analyze SELECT g, 'new ' || g FROM generate_series(1, 100) g;
+ANALYZE gtt_analyze;
+SELECT count(*) FROM gtt_analyze WHERE id <= 50;
+
+-- Verify no column stats leaked to pg_statistic after re-ANALYZE
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_analyze'::regclass;
+
+-- Verify per-session stats don't survive reconnect
+\c -
+-- New session sees shared pg_class (unchanged by ANALYZE)
+SELECT relpages, reltuples FROM pg_class WHERE relname = 'gtt_analyze';
+
+-- No column stats in new session either
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_analyze'::regclass;
+
+DROP TABLE gtt_analyze;
+
+--
+-- Per-session column statistics: detailed tests
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_colstats (id int, category text, val float);
+INSERT INTO gtt_colstats
+    SELECT g, 'cat_' || (g % 5), random() * 100
+    FROM generate_series(1, 10000) g;
+CREATE INDEX ON gtt_colstats (category);
+ANALYZE gtt_colstats;
+
+-- No column stats in shared catalog
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_colstats'::regclass;
+
+-- Planner should use per-session stats for category selectivity
+-- With 5 distinct categories, ~2000 rows per category
+EXPLAIN (COSTS OFF) SELECT * FROM gtt_colstats WHERE category = 'cat_0';
+
+-- Index stats should also be collected per-session
+SELECT count(*) FROM pg_statistic
+    WHERE starelid = (SELECT oid FROM pg_class WHERE relname = 'gtt_colstats_category_idx');
+
+-- ON COMMIT DELETE ROWS should reset column stats
+CREATE GLOBAL TEMPORARY TABLE gtt_colstats_ocd (id int, cat text) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_colstats_ocd SELECT g, 'c' || (g % 3) FROM generate_series(1, 1000) g;
+ANALYZE gtt_colstats_ocd;
+COMMIT;
+-- After commit, data is gone and stats should be invalidated
+-- New data with different distribution
+BEGIN;
+INSERT INTO gtt_colstats_ocd SELECT g, 'x' FROM generate_series(1, 100) g;
+SELECT count(*) FROM gtt_colstats_ocd;
+COMMIT;
+DROP TABLE gtt_colstats_ocd;
+
+-- Column stats should not survive reconnect
+\c -
+INSERT INTO gtt_colstats SELECT g, 'cat_' || (g % 5), random() * 100 FROM generate_series(1, 100) g;
+-- Without ANALYZE in this session, no per-session column stats
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_colstats'::regclass;
+
+DROP TABLE gtt_colstats;
+
+--
+-- Per-session stats visibility via SRFs
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_srf_test (id int, category text, val float);
+INSERT INTO gtt_srf_test
+    SELECT g, 'cat_' || (g % 5), random() * 100
+    FROM generate_series(1, 1000) g;
+ANALYZE gtt_srf_test;
+
+-- pg_gtt_relstats should show relation-level stats
+SELECT table_name, relpages > 0 AS has_pages, reltuples > 0 AS has_tuples
+    FROM pg_gtt_relstats('gtt_srf_test'::regclass);
+
+-- pg_gtt_relstats with NULL shows all GTTs (just this one)
+SELECT table_name FROM pg_gtt_relstats() ORDER BY table_name;
+
+-- pg_gtt_colstats should show column-level stats
+SELECT attname, null_frac IS NOT NULL AS has_null_frac,
+       avg_width > 0 AS has_avg_width,
+       n_distinct != 0 AS has_n_distinct
+    FROM pg_gtt_colstats('gtt_srf_test'::regclass)
+    WHERE NOT inherited
+    ORDER BY attnum;
+
+-- Category column should have MCV data
+SELECT attname, most_common_vals IS NOT NULL AS has_mcv,
+       most_common_freqs IS NOT NULL AS has_mcf,
+       histogram_bounds IS NOT NULL AS has_hist,
+       correlation IS NOT NULL AS has_corr
+    FROM pg_gtt_colstats('gtt_srf_test'::regclass)
+    WHERE attname = 'category' AND NOT inherited;
+
+-- id column should have histogram bounds
+SELECT attname, histogram_bounds IS NOT NULL AS has_hist,
+       correlation IS NOT NULL AS has_corr
+    FROM pg_gtt_colstats('gtt_srf_test'::regclass)
+    WHERE attname = 'id' AND NOT inherited;
+
+-- Stats should not be in pg_statistic
+SELECT count(*) FROM pg_statistic WHERE starelid = 'gtt_srf_test'::regclass;
+
+-- After TRUNCATE, relation-level stats are invalidated; column-level
+-- stats are retained, as pg_statistic is for regular tables
+TRUNCATE gtt_srf_test;
+SELECT count(*) FROM pg_gtt_relstats('gtt_srf_test'::regclass);
+SELECT count(*) FROM pg_gtt_colstats('gtt_srf_test'::regclass);
+
+-- Re-insert and re-analyze to get stats back
+INSERT INTO gtt_srf_test SELECT g, 'x', g FROM generate_series(1, 100) g;
+ANALYZE gtt_srf_test;
+SELECT table_name, reltuples FROM pg_gtt_relstats('gtt_srf_test'::regclass);
+
+-- After reconnect, stats should be gone
+\c -
+SELECT count(*) FROM pg_gtt_relstats('gtt_srf_test'::regclass);
+SELECT count(*) FROM pg_gtt_colstats('gtt_srf_test'::regclass);
+
+DROP TABLE gtt_srf_test;
+
+--
+-- pg_gtt_clear_stats: discard per-session stats explicitly
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_clear_stats (id int, val text);
+INSERT INTO gtt_clear_stats SELECT g, 'v' || g FROM generate_series(1, 50) g;
+ANALYZE gtt_clear_stats;
+-- Stats are present
+SELECT count(*) FROM pg_gtt_relstats('gtt_clear_stats'::regclass);
+SELECT count(*) > 0 AS has_colstats FROM pg_gtt_colstats('gtt_clear_stats'::regclass);
+
+-- Clear and verify both rel- and col-level stats are gone
+SELECT pg_gtt_clear_stats('gtt_clear_stats'::regclass);
+SELECT count(*) FROM pg_gtt_relstats('gtt_clear_stats'::regclass);
+SELECT count(*) FROM pg_gtt_colstats('gtt_clear_stats'::regclass);
+
+-- A subsequent ANALYZE repopulates them
+ANALYZE gtt_clear_stats;
+SELECT count(*) FROM pg_gtt_relstats('gtt_clear_stats'::regclass);
+
+-- NULL clears stats for every GTT this session has touched
+CREATE GLOBAL TEMPORARY TABLE gtt_clear_stats2 (a int);
+INSERT INTO gtt_clear_stats2 SELECT generate_series(1, 20);
+ANALYZE gtt_clear_stats2;
+SELECT count(*) FROM pg_gtt_relstats();
+SELECT pg_gtt_clear_stats(NULL);
+SELECT count(*) FROM pg_gtt_relstats();
+
+DROP TABLE gtt_clear_stats, gtt_clear_stats2;
+
+--
+-- pg_restore_relation_stats / pg_restore_attribute_stats reject GTTs
+--
+-- The shared pg_class and pg_statistic rows for a GTT are never read by
+-- the planner: GttGetSessionStats() / SearchStats() return
+-- session-private values from the per-session hash, falling back on the
+-- syscache only when the current session has not run ANALYZE.  Allowing
+-- pg_restore_*_stats to write the shared values would surface them to
+-- exactly that fallback path, undermining the per-session isolation.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_restore_stats (a int, b text);
+
+SELECT pg_restore_relation_stats(
+    'schemaname', 'public',
+    'relname', 'gtt_restore_stats',
+    'relpages', 42::integer,
+    'reltuples', 1000::real);
+
+SELECT pg_clear_relation_stats('public', 'gtt_restore_stats');
+
+SELECT pg_restore_attribute_stats(
+    'schemaname', 'public',
+    'relname', 'gtt_restore_stats',
+    'attname', 'a',
+    'inherited', false,
+    'null_frac', 0.0::real,
+    'avg_width', 4::integer,
+    'n_distinct', -1.0::real);
+
+SELECT pg_clear_attribute_stats('public', 'gtt_restore_stats', 'a', false);
+
+DROP TABLE gtt_restore_stats;
+
+--
+-- Subtransaction abort after first access to a GTT
+-- The per-session storage file is unlinked by PendingRelDelete when the
+-- subxact aborts; the hash entry must be reset so a subsequent access
+-- in the outer transaction re-creates the storage, not error out.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact (x int);
+BEGIN;
+SAVEPOINT s;
+INSERT INTO gtt_subxact VALUES (1);  -- first access, creates storage
+ROLLBACK TO SAVEPOINT s;             -- storage unlinked, hash entry cleaned
+-- outer xact continues: next access must re-create storage cleanly
+INSERT INTO gtt_subxact VALUES (2);
+SELECT * FROM gtt_subxact;
+COMMIT;
+SELECT * FROM gtt_subxact;
+DROP TABLE gtt_subxact;
+
+--
+-- DROP then recreate a same-named GTT in the same session
+-- Exercises the commit-side cleanup in gtt_xact_callback: the hash
+-- entry from the dropped table must be gone so the new CREATE uses
+-- fresh per-session state.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_recreate (x int);
+INSERT INTO gtt_recreate VALUES (1);
+DROP TABLE gtt_recreate;
+CREATE GLOBAL TEMPORARY TABLE gtt_recreate (y text);
+INSERT INTO gtt_recreate VALUES ('fresh');
+SELECT * FROM gtt_recreate;
+DROP TABLE gtt_recreate;
+
+--
+-- ALTER TABLE SET TABLESPACE on a GTT is rejected
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_notbs (x int);
+ALTER TABLE gtt_notbs SET TABLESPACE pg_default;  -- ERROR
+DROP TABLE gtt_notbs;
+
+--
+-- ALTER TABLE INHERIT / NO INHERIT with GTT persistence mixing
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_parent2 (x int);
+CREATE TABLE perm_child2 (x int);
+ALTER TABLE perm_child2 INHERIT gtt_parent2;         -- ERROR (permanent into GTT)
+CREATE TEMPORARY TABLE temp_child2 (x int);
+ALTER TABLE temp_child2 INHERIT gtt_parent2;         -- ERROR (local temp into GTT)
+CREATE GLOBAL TEMPORARY TABLE gtt_child2 (x int);
+ALTER TABLE gtt_child2 INHERIT gtt_parent2;          -- OK: GTT→GTT
+ALTER TABLE gtt_child2 NO INHERIT gtt_parent2;
+DROP TABLE gtt_child2, gtt_parent2, perm_child2, temp_child2;
+
+--
+-- ATTACH PARTITION with GTT persistence mixing
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_part (x int) PARTITION BY RANGE (x);
+CREATE TABLE perm_part (x int);
+ALTER TABLE gtt_part ATTACH PARTITION perm_part
+    FOR VALUES FROM (0) TO (10);                     -- ERROR
+CREATE TEMPORARY TABLE temp_part (x int);
+ALTER TABLE gtt_part ATTACH PARTITION temp_part
+    FOR VALUES FROM (0) TO (10);                     -- ERROR
+CREATE GLOBAL TEMPORARY TABLE gtt_part_child (x int);
+ALTER TABLE gtt_part ATTACH PARTITION gtt_part_child
+    FOR VALUES FROM (0) TO (10);                     -- OK
+DROP TABLE gtt_part, perm_part, temp_part;
+
+--
+-- CREATE TABLE ... PARTITION OF persistence mixing with a GTT
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_prange (a int) PARTITION BY RANGE (a);
+-- a permanent partition of a GTT parent is rejected
+CREATE TABLE gtt_prange_perm PARTITION OF gtt_prange
+    FOR VALUES FROM (0) TO (10);                     -- ERROR
+-- a local temporary partition of a GTT parent is rejected
+CREATE TEMPORARY TABLE gtt_prange_tmp PARTITION OF gtt_prange
+    FOR VALUES FROM (0) TO (10);                     -- ERROR: cannot mix
+-- a GTT partition of a permanent parent is rejected
+CREATE TABLE perm_prange (a int) PARTITION BY RANGE (a);
+CREATE GLOBAL TEMPORARY TABLE perm_prange_gtt PARTITION OF perm_prange
+    FOR VALUES FROM (0) TO (10);                     -- ERROR
+DROP TABLE perm_prange;
+-- a GTT partition of a GTT parent is OK
+CREATE GLOBAL TEMPORARY TABLE gtt_prange_1 PARTITION OF gtt_prange
+    FOR VALUES FROM (0) TO (10);                     -- OK
+
+--
+-- SPLIT/MERGE PARTITION is not supported for global temporary tables: the new
+-- partition would not inherit the parent's persistence and would wrongly get
+-- permanent, cluster-wide storage under a per-session parent.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_prange_2 PARTITION OF gtt_prange
+    FOR VALUES FROM (10) TO (20);
+ALTER TABLE gtt_prange SPLIT PARTITION gtt_prange_2 INTO
+    (PARTITION gtt_prange_2a FOR VALUES FROM (10) TO (15),
+     PARTITION gtt_prange_2b FOR VALUES FROM (15) TO (20));  -- ERROR
+ALTER TABLE gtt_prange MERGE PARTITIONS (gtt_prange_1, gtt_prange_2)
+    INTO gtt_prange_m;                               -- ERROR
+DROP TABLE gtt_prange;
+
+--
+-- Property graphs and GTTs
+--
+CREATE TABLE gtt_pg_perm_v (id int PRIMARY KEY);
+CREATE GLOBAL TEMPORARY TABLE gtt_pg_gtt_v (id int PRIMARY KEY);
+-- A property graph itself cannot be global temporary (it has no storage).
+CREATE GLOBAL TEMPORARY PROPERTY GRAPH gtt_pg_self
+    VERTEX TABLES (gtt_pg_perm_v KEY (id));           -- ERROR
+-- But a GTT may serve as an element table of a permanent property graph,
+-- because the GTT's definition is permanent; the graph stays permanent and a
+-- dependency on the GTT is recorded.
+CREATE PROPERTY GRAPH gtt_pg
+    VERTEX TABLES (gtt_pg_perm_v KEY (id), gtt_pg_gtt_v KEY (id));
+SELECT relpersistence FROM pg_class WHERE relname = 'gtt_pg';
+DROP TABLE gtt_pg_gtt_v;                              -- ERROR: graph depends on it
+DROP PROPERTY GRAPH gtt_pg;
+DROP TABLE gtt_pg_perm_v, gtt_pg_gtt_v;
+
+--
+-- on_commit_delete reloption is internal: user cannot set it directly
+--
+CREATE TABLE perm_ocd (x int) WITH (on_commit_delete = true);  -- ERROR
+CREATE GLOBAL TEMPORARY TABLE gtt_ocd (x int)
+  WITH (on_commit_delete = true);                    -- ERROR: internal reloption
+CREATE GLOBAL TEMPORARY TABLE gtt_ocd (x int) ON COMMIT DELETE ROWS;
+ALTER TABLE gtt_ocd SET (on_commit_delete = false);  -- ERROR
+ALTER TABLE gtt_ocd RESET (on_commit_delete);        -- ERROR
+DROP TABLE gtt_ocd;
+
+--
+-- pg_relation_filepath on a GTT returns NULL before first access,
+-- and a session-local path after.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_fp (x int);
+-- Run from a fresh session so the table has no per-session storage
+\c -
+-- pg_relation_filepath/pg_relation_size read session-local GTT storage state
+-- that a parallel worker cannot see, so they must run in the leader; force
+-- that here so the checks are stable under debug_parallel_query.
+SET debug_parallel_query = off;
+SELECT pg_relation_filepath('gtt_fp') IS NULL AS no_storage_yet;
+INSERT INTO gtt_fp VALUES (1);
+-- After first access, the path is the session-local file name (t<proc>_<rel>)
+SELECT pg_relation_filepath('gtt_fp') ~ '/t[0-9]+_[0-9]+$' AS got_session_path;
+RESET debug_parallel_query;
+DROP TABLE gtt_fp;
+
+--
+-- pg_gtt_relstats / pg_gtt_colstats honour SELECT privilege
+--
+CREATE ROLE regress_gtt_acl_role;
+CREATE GLOBAL TEMPORARY TABLE gtt_acl (a int, b text);
+INSERT INTO gtt_acl
+SELECT i, repeat('x', i % 10)
+FROM generate_series(1, 100) i;
+ANALYZE gtt_acl;
+-- Owner sees stats
+SELECT count(*) > 0 FROM pg_gtt_relstats('gtt_acl'::regclass);
+SELECT count(*) > 0 FROM pg_gtt_colstats('gtt_acl'::regclass);
+-- Unprivileged role sees nothing
+SET ROLE regress_gtt_acl_role;
+SELECT count(*) FROM pg_gtt_relstats('gtt_acl'::regclass);
+SELECT count(*) FROM pg_gtt_colstats('gtt_acl'::regclass);
+RESET ROLE;
+-- Grant table-level SELECT: both SRFs become visible
+GRANT SELECT ON gtt_acl TO regress_gtt_acl_role;
+SET ROLE regress_gtt_acl_role;
+SELECT count(*) > 0 FROM pg_gtt_relstats('gtt_acl'::regclass);
+SELECT count(*) > 0 FROM pg_gtt_colstats('gtt_acl'::regclass);
+RESET ROLE;
+REVOKE SELECT ON gtt_acl FROM regress_gtt_acl_role;
+-- Grant column-level SELECT on just one column: colstats filters per-column
+GRANT SELECT (a) ON gtt_acl TO regress_gtt_acl_role;
+SET ROLE regress_gtt_acl_role;
+SELECT attname FROM pg_gtt_colstats('gtt_acl'::regclass) ORDER BY attname;
+RESET ROLE;
+DROP TABLE gtt_acl;
+DROP ROLE regress_gtt_acl_role;
+
+--
+-- ON COMMIT DELETE ROWS with FK between two GTTs
+-- PreCommit_gtt_on_commit must batch the truncation so
+-- heap_truncate_check_FKs validates FK integrity across the set,
+-- matching regular-temp ON COMMIT DELETE ROWS behaviour.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_ocdr_pk (id int PRIMARY KEY)
+    ON COMMIT DELETE ROWS;
+CREATE GLOBAL TEMPORARY TABLE gtt_ocdr_fk (id int REFERENCES gtt_ocdr_pk(id))
+    ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_ocdr_pk VALUES (1), (2);
+INSERT INTO gtt_ocdr_fk VALUES (1);
+COMMIT;
+-- Both should be empty after commit; neither should have errored
+-- during commit's truncation
+SELECT count(*) FROM gtt_ocdr_pk;
+SELECT count(*) FROM gtt_ocdr_fk;
+-- Next transaction works cleanly
+BEGIN;
+INSERT INTO gtt_ocdr_pk VALUES (10);
+INSERT INTO gtt_ocdr_fk VALUES (10);
+SELECT count(*) FROM gtt_ocdr_pk;
+SELECT count(*) FROM gtt_ocdr_fk;
+COMMIT;
+DROP TABLE gtt_ocdr_fk;
+DROP TABLE gtt_ocdr_pk;
+
+--
+-- ALTER TABLE operations that would rewrite the heap are rejected,
+-- because rewriting rotates the catalog relfilenode that every
+-- session's per-session storage is keyed by.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_norewrite (a int, b varchar(10));
+INSERT INTO gtt_norewrite VALUES (1, 'hi');
+-- Type change that forces a rewrite: ERROR
+ALTER TABLE gtt_norewrite ALTER COLUMN a TYPE bigint;  -- ERROR
+-- Binary-coercible varchar length change (no rewrite): OK
+ALTER TABLE gtt_norewrite ALTER COLUMN b TYPE varchar(20);
+SELECT * FROM gtt_norewrite;
+-- ADD COLUMN with volatile default (forces rewrite): ERROR
+ALTER TABLE gtt_norewrite ADD COLUMN c int DEFAULT random()::int;  -- ERROR
+-- ADD COLUMN with constant default (no rewrite on modern PG): OK
+ALTER TABLE gtt_norewrite ADD COLUMN d int DEFAULT 42;
+SELECT * FROM gtt_norewrite;
+DROP TABLE gtt_norewrite;
+
+--
+-- CREATE TABLE AS and SELECT INTO create GTTs correctly
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_cta AS
+    SELECT i AS x, 'row_' || i::text AS y FROM generate_series(1, 10) i;
+SELECT relpersistence FROM pg_class WHERE relname = 'gtt_cta';
+SELECT count(*) FROM gtt_cta;
+-- Data is per-session; reconnect sees no rows
+\c -
+SELECT count(*) FROM gtt_cta;
+DROP TABLE gtt_cta;
+
+-- SELECT INTO GLOBAL TEMPORARY should now produce a GTT, not a local TEMP
+SELECT i AS x INTO GLOBAL TEMPORARY gtt_si FROM generate_series(1, 5) i;
+SELECT relpersistence FROM pg_class WHERE relname = 'gtt_si';
+SELECT count(*) FROM gtt_si;
+\c -
+SELECT count(*) FROM gtt_si;
+DROP TABLE gtt_si;
+
+--
+-- WITH HOLD cursor on an ON COMMIT DELETE ROWS GTT.
+-- PreCommit_Portals(false) materialises held portals before
+-- PreCommit_gtt_on_commit truncates per-session data, so the
+-- cursor's rows survive the commit.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_hold_ocdr (x int) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_hold_ocdr SELECT i FROM generate_series(1, 5) i;
+DECLARE gtt_hold_cur CURSOR WITH HOLD FOR
+    SELECT * FROM gtt_hold_ocdr ORDER BY x;
+COMMIT;
+-- Table was truncated at commit, but the held cursor materialised first
+SELECT count(*) FROM gtt_hold_ocdr;
+FETCH ALL FROM gtt_hold_cur;
+CLOSE gtt_hold_cur;
+DROP TABLE gtt_hold_ocdr;
+
+--
+-- Subtransaction rollback
+--
+-- gtt_subxact_callback has to reconcile entries whose create/storage/index
+-- state was established inside an aborted savepoint.  Verify that data
+-- mutations in a rolled-back savepoint are not visible afterwards, and
+-- that the GTT remains usable on the next top-level transaction.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact (x int);
+BEGIN;
+INSERT INTO gtt_subxact VALUES (1);
+SAVEPOINT sp;
+INSERT INTO gtt_subxact VALUES (2), (3);
+SELECT count(*) FROM gtt_subxact;  -- 3
+ROLLBACK TO SAVEPOINT sp;
+SELECT count(*) FROM gtt_subxact;  -- 1
+COMMIT;
+SELECT count(*) FROM gtt_subxact;  -- 1
+-- Subxact that creates a GTT, then rolls back: the relation vanishes
+BEGIN;
+SAVEPOINT sp;
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact_new (y int);
+INSERT INTO gtt_subxact_new VALUES (42);
+ROLLBACK TO SAVEPOINT sp;
+SELECT 1 FROM pg_class WHERE relname = 'gtt_subxact_new';  -- 0 rows
+COMMIT;
+DROP TABLE gtt_subxact;
+
+--
+-- Self-drop: a session that has touched a GTT must be able to DROP it.
+-- GttCheckDroppable skips entries matching our own ProcNumber.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_selfdrop (x int);
+INSERT INTO gtt_selfdrop VALUES (1), (2);
+SELECT count(*) FROM gtt_selfdrop;
+DROP TABLE gtt_selfdrop;
+
+--
+-- ON COMMIT DELETE ROWS resets per-session statistics.
+-- PreCommit_gtt_on_commit calls GttResetSessionStats after truncating, so
+-- after commit the planner should see no per-session stats until the next
+-- ANALYZE.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_stats_ocdr (x int) ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO gtt_stats_ocdr SELECT g FROM generate_series(1, 100) g;
+ANALYZE gtt_stats_ocdr;
+-- Stats are visible within the transaction
+SELECT table_name FROM pg_gtt_relstats()
+ WHERE table_name = 'gtt_stats_ocdr';
+COMMIT;
+-- Commit truncated the data and cleared per-session stats
+SELECT table_name FROM pg_gtt_relstats()
+ WHERE table_name = 'gtt_stats_ocdr';
+DROP TABLE gtt_stats_ocdr;
+
+--
+-- Subtransaction-abort counterpart of the gtt_abort_recreate test.  When
+-- the per-session entry is FIRST CREATED inside a subxact, ROLLBACK TO
+-- SAVEPOINT removes that entry and unlinks the file (PendingRelDelete is
+-- subxact-aware).  Without the relcache invalidation in
+-- gtt_subxact_callback, the outer xact's relcache entry would keep
+-- pointing at the now-deleted file.  Force a fresh session with \c -
+-- so this session has no hash entry going in; then the first INSERT in
+-- SAVEPOINT s1 hits the !found branch with create_subid = s1.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_subxact_abort (x int) ON COMMIT DELETE ROWS;
+\c -
+BEGIN;
+SAVEPOINT s1;
+INSERT INTO gtt_subxact_abort VALUES (1);
+ROLLBACK TO SAVEPOINT s1;
+SELECT * FROM gtt_subxact_abort;        -- no error, 0 rows
+INSERT INTO gtt_subxact_abort VALUES (99);
+SELECT * FROM gtt_subxact_abort;
+COMMIT;
+SELECT * FROM gtt_subxact_abort;        -- empty after ON COMMIT DELETE ROWS
+DROP TABLE gtt_subxact_abort;
+
+--
+-- Heap-only access method enforcement: a GTT may only use the heap table
+-- access method (its per-session storage and wraparound handling are
+-- heap-specific).  A non-heap access method is rejected at CREATE, whether
+-- requested with USING or inherited from default_table_access_method.  Use a
+-- second AM OID that reuses heap's handler to exercise the check without a
+-- separate AM implementation.
+--
+CREATE ACCESS METHOD gtt_fake_heap TYPE TABLE HANDLER heap_tableam_handler;
+CREATE GLOBAL TEMPORARY TABLE gtt_am_bad (a int) USING gtt_fake_heap;  -- error
+SET default_table_access_method = gtt_fake_heap;
+CREATE GLOBAL TEMPORARY TABLE gtt_am_bad (a int);                     -- error
+RESET default_table_access_method;
+CREATE GLOBAL TEMPORARY TABLE gtt_am_ok (a int) USING heap;           -- ok
+DROP TABLE gtt_am_ok;
+CREATE TABLE gtt_am_reg (a int) USING gtt_fake_heap;                  -- ok (not a GTT)
+DROP TABLE gtt_am_reg;
+DROP ACCESS METHOD gtt_fake_heap;
+
+--
+-- global_temp_xid_warn_margin GUC: controls the head room before the
+-- transaction-ID horizon at which a warning is issued for aging GTT data.
+-- The hard error is fixed at the horizon and is exercised by a TAP test
+-- (the warning/error cannot be triggered deterministically here).
+--
+SHOW global_temp_xid_warn_margin;                 -- default 100000000
+SET global_temp_xid_warn_margin = 0;              -- disables the warning
+SHOW global_temp_xid_warn_margin;
+SET global_temp_xid_warn_margin = 250000000;
+SHOW global_temp_xid_warn_margin;
+SET global_temp_xid_warn_margin = -1;             -- error: below minimum
+SET global_temp_xid_warn_margin = 3000000000;     -- error: above maximum
+RESET global_temp_xid_warn_margin;
+
+--
+-- TRUNCATE of a GTT is transaction-safe: this session's storage is swapped
+-- for new, empty files (the shared catalog relfilenode never changes), so
+-- ROLLBACK restores the rows, including across savepoints, and indexes
+-- remain consistent afterwards.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_trunc_xact (id int PRIMARY KEY, t text);
+INSERT INTO gtt_trunc_xact SELECT g, 'x' || g FROM generate_series(1, 100) g;
+-- catalog relfilenode is stable across TRUNCATE (it equals the OID at
+-- creation, and must still do so afterwards)
+SELECT relfilenode = oid AS filenode_premise
+  FROM pg_class WHERE relname = 'gtt_trunc_xact';
+BEGIN;
+TRUNCATE gtt_trunc_xact;
+SELECT count(*) FROM gtt_trunc_xact;              -- 0 inside the transaction
+ROLLBACK;
+SELECT count(*) FROM gtt_trunc_xact;              -- 100 again
+BEGIN;
+SAVEPOINT s1;
+TRUNCATE gtt_trunc_xact;
+ROLLBACK TO s1;
+SELECT count(*) FROM gtt_trunc_xact;              -- 100: subxact rollback
+SAVEPOINT s2;
+TRUNCATE gtt_trunc_xact;
+RELEASE s2;
+COMMIT;
+SELECT count(*) FROM gtt_trunc_xact;              -- 0: released swap commits
+-- index is rebuilt correctly after a rolled-back truncate
+INSERT INTO gtt_trunc_xact SELECT g, 'y' || g FROM generate_series(1, 30) g;
+BEGIN;
+TRUNCATE gtt_trunc_xact;
+INSERT INTO gtt_trunc_xact VALUES (7, 'seven');
+ROLLBACK;
+SET enable_seqscan = off;
+SELECT t FROM gtt_trunc_xact WHERE id = 13;       -- index scan finds old row
+RESET enable_seqscan;
+SELECT relfilenode = oid AS filenode_unchanged
+  FROM pg_class WHERE relname = 'gtt_trunc_xact';
+DROP TABLE gtt_trunc_xact;
+
+--
+-- Sequence RESTART paths swap only the session-local storage as well.
+--
+CREATE GLOBAL TEMPORARY SEQUENCE gtt_seq_restart;
+SELECT nextval('gtt_seq_restart'), nextval('gtt_seq_restart');
+ALTER SEQUENCE gtt_seq_restart RESTART;
+SELECT nextval('gtt_seq_restart');                -- 1 again
+SELECT relfilenode = oid AS filenode_unchanged
+  FROM pg_class WHERE relname = 'gtt_seq_restart';
+DROP SEQUENCE gtt_seq_restart;
+-- TRUNCATE ... RESTART IDENTITY, including rollback
+CREATE GLOBAL TEMPORARY TABLE gtt_ident (id int GENERATED ALWAYS AS IDENTITY, v text);
+INSERT INTO gtt_ident (v) VALUES ('a'), ('b'), ('c');
+TRUNCATE gtt_ident RESTART IDENTITY;
+INSERT INTO gtt_ident (v) VALUES ('d');
+SELECT id, v FROM gtt_ident;                      -- id restarts at 1
+BEGIN;
+TRUNCATE gtt_ident RESTART IDENTITY;
+ROLLBACK;
+INSERT INTO gtt_ident (v) VALUES ('e');
+SELECT count(*), max(id) FROM gtt_ident;          -- row back, id continues
+DROP TABLE gtt_ident;
+
+--
+-- A GTT sequence presents its one mandatory row to any session, even via a
+-- direct scan that bypasses the sequence functions (as psql's \d does).
+--
+CREATE GLOBAL TEMPORARY SEQUENCE gtt_seq_scan START 42;
+SELECT last_value, is_called FROM gtt_seq_scan;   -- creator's view
+\c -
+SELECT last_value, is_called FROM gtt_seq_scan;   -- fresh session: same seed
+SELECT nextval('gtt_seq_scan');
+DROP SEQUENCE gtt_seq_scan;
+
+--
+-- Relations without storage cannot be global temporary.
+--
+CREATE GLOBAL TEMPORARY VIEW gtt_view_bad AS SELECT 1;          -- error
+CREATE OR REPLACE GLOBAL TEMPORARY VIEW gtt_view_bad AS SELECT 1;  -- error
+CREATE GLOBAL TEMPORARY RECURSIVE VIEW gtt_view_bad (n) AS SELECT 1;  -- error
+
+--
+-- ON COMMIT DELETE ROWS reclaims TOAST storage along with the heap.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_toast_ocdr (id int, blob text)
+  ON COMMIT DELETE ROWS;
+-- pg_relation_size reads session-local GTT storage; keep it in the leader.
+SET debug_parallel_query = off;
+BEGIN;
+INSERT INTO gtt_toast_ocdr
+  SELECT g, string_agg(md5(g::text || i::text), '')
+  FROM generate_series(1, 5) g, generate_series(1, 200) i GROUP BY g;
+SELECT pg_relation_size(reltoastrelid) > 0 AS toast_used_in_xact
+  FROM pg_class WHERE relname = 'gtt_toast_ocdr';
+COMMIT;
+SELECT pg_relation_size(reltoastrelid) AS toast_size_after_commit
+  FROM pg_class WHERE relname = 'gtt_toast_ocdr';
+RESET debug_parallel_query;
+SELECT count(*) FROM gtt_toast_ocdr;
+DROP TABLE gtt_toast_ocdr;
+
+--
+-- Materialized views must not capture session-private GTT data into a
+-- permanent relation: rejected both for direct references (at creation)
+-- and for references through a view (at population time).
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_mv (x int);
+INSERT INTO gtt_mv VALUES (1), (2), (3);
+CREATE MATERIALIZED VIEW gtt_mv_direct AS SELECT x FROM gtt_mv;     -- error
+CREATE MATERIALIZED VIEW gtt_mv_nodata AS SELECT x FROM gtt_mv
+  WITH NO DATA;                                                     -- error
+-- a plain view over a GTT stays permanent and is fine
+CREATE VIEW gtt_mv_view AS SELECT x FROM gtt_mv;
+SELECT relpersistence FROM pg_class WHERE relname = 'gtt_mv_view';
+SELECT count(*) FROM gtt_mv_view;
+-- ... but materializing through the view is caught at population time
+CREATE MATERIALIZED VIEW gtt_mv_indirect AS SELECT x FROM gtt_mv_view;  -- error
+DROP VIEW gtt_mv_view;
+DROP TABLE gtt_mv;
+
+--
+-- Per-session statistics roll back with the transaction that wrote them.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_stats_abort (id int, cat text);
+BEGIN;
+INSERT INTO gtt_stats_abort SELECT g, 'c' || (g % 4) FROM generate_series(1, 10000) g;
+ANALYZE gtt_stats_abort;
+SELECT reltuples FROM pg_gtt_relstats('gtt_stats_abort'::regclass);
+ROLLBACK;
+SELECT count(*) FROM gtt_stats_abort;
+SELECT count(*) FROM pg_gtt_relstats('gtt_stats_abort'::regclass);  -- 0: stats gone
+SELECT count(*) FROM pg_gtt_colstats('gtt_stats_abort'::regclass);  -- 0: colstats too
+-- subtransaction variant
+INSERT INTO gtt_stats_abort SELECT g, 'x' FROM generate_series(1, 100) g;
+BEGIN;
+SAVEPOINT s1;
+ANALYZE gtt_stats_abort;
+ROLLBACK TO s1;
+COMMIT;
+SELECT count(*) FROM pg_gtt_relstats('gtt_stats_abort'::regclass);  -- 0
+-- committed ANALYZE still sticks
+ANALYZE gtt_stats_abort;
+SELECT reltuples FROM pg_gtt_relstats('gtt_stats_abort'::regclass);
+DROP TABLE gtt_stats_abort;
+
+--
+-- VACUUM of a GTT whose toast table has no session data stays quiet about
+-- the toast relation (only the named relation is reported when skipped).
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_vac_toast (id int PRIMARY KEY, pad text);
+INSERT INTO gtt_vac_toast SELECT g, repeat('y', 100) FROM generate_series(1, 100) g;
+VACUUM gtt_vac_toast;       -- no INFO about pg_toast_NNN
+DROP TABLE gtt_vac_toast;
+
+--
+-- DISCARD TEMP / DISCARD ALL clear per-session GTT data (and reset GTT
+-- sequences), transactionally.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_disc (x int);
+CREATE GLOBAL TEMPORARY SEQUENCE gtt_disc_seq;
+INSERT INTO gtt_disc VALUES (1), (2), (3);
+SELECT nextval('gtt_disc_seq'), nextval('gtt_disc_seq');
+DISCARD TEMP;
+SELECT count(*) AS rows_after_discard FROM gtt_disc;
+SELECT nextval('gtt_disc_seq') AS seq_after_discard;      -- restarts at 1
+-- inside a transaction block, DISCARD TEMP rolls back cleanly
+INSERT INTO gtt_disc VALUES (4), (5);
+BEGIN;
+DISCARD TEMP;
+SELECT count(*) FROM gtt_disc;                            -- 0 inside
+ROLLBACK;
+SELECT count(*) AS rows_restored FROM gtt_disc;           -- 2 again
+DROP SEQUENCE gtt_disc_seq;
+DROP TABLE gtt_disc;
+
+--
+-- Lazy storage creation: a session that merely opens, plans, or reads a
+-- GTT holds no per-session file (and so does not block peer DDL); files
+-- appear at the first genuine data access.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_lazy (id int PRIMARY KEY, t text);
+INSERT INTO gtt_lazy VALUES (1, 'one');
+\c -
+-- The pg_relation_filepath/pg_relation_size probes below read session-local
+-- GTT storage that a parallel worker cannot see, so force leader execution.
+-- Each \c - starts a fresh session and resets this, so it is re-applied below.
+SET debug_parallel_query = off;
+-- reads and planning do not materialize
+SELECT count(*) FROM gtt_lazy;
+EXPLAIN (COSTS OFF) SELECT * FROM gtt_lazy WHERE id = 1;
+SELECT pg_relation_filepath('gtt_lazy') IS NULL AS heap_unmaterialized,
+       pg_relation_filepath('gtt_lazy_pkey') IS NULL AS index_unmaterialized;
+SELECT pg_relation_size('gtt_lazy') AS size_unmaterialized;
+-- an index scan materializes (and builds) only the index, not the heap
+SET enable_seqscan = off;
+SELECT * FROM gtt_lazy WHERE id = 1;
+RESET enable_seqscan;
+SELECT pg_relation_filepath('gtt_lazy') IS NULL AS heap_still_unmaterialized,
+       pg_relation_filepath('gtt_lazy_pkey') IS NOT NULL AS index_materialized;
+-- maintenance on unmaterialized storage is a no-op
+TRUNCATE gtt_lazy;
+ANALYZE gtt_lazy;
+SELECT count(*) FROM pg_gtt_relstats('gtt_lazy'::regclass);
+SELECT pg_relation_filepath('gtt_lazy') IS NULL AS still_unmaterialized;
+-- the first write materializes the heap and its indexes together
+INSERT INTO gtt_lazy VALUES (2, 'two');
+SELECT pg_relation_filepath('gtt_lazy') IS NOT NULL AS heap_materialized;
+SET enable_seqscan = off;
+SELECT t FROM gtt_lazy WHERE id = 2;
+RESET enable_seqscan;
+-- rollback of the materializing transaction discards the storage again
+\c -
+SET debug_parallel_query = off;		-- GTT storage probes must run in the leader
+BEGIN;
+INSERT INTO gtt_lazy VALUES (3, 'three');
+SELECT pg_relation_filepath('gtt_lazy') IS NOT NULL AS materialized_in_xact;
+ROLLBACK;
+SELECT pg_relation_filepath('gtt_lazy') IS NULL AS dematerialized_after_abort;
+SELECT count(*) FROM gtt_lazy;
+DROP TABLE gtt_lazy;
+
+--
+-- Lazy creation round 2: indexes are exactly as lazy as their heap, and
+-- a rollback that dematerializes the heap leaves no stale index content.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_lz2 (id int PRIMARY KEY);
+-- bare CREATE materializes nothing (and so blocks no peer DDL)
+SELECT pg_relation_filepath('gtt_lz2') IS NULL AS heap_unmat,
+       pg_relation_filepath('gtt_lz2_pkey') IS NULL AS pkey_unmat;
+-- write + rollback returns to fully unmaterialized; no phantom index entries
+BEGIN;
+INSERT INTO gtt_lz2 SELECT generate_series(1, 5);
+ROLLBACK;
+SELECT pg_relation_filepath('gtt_lz2') IS NULL AS heap_unmat_after_abort,
+       pg_relation_filepath('gtt_lz2_pkey') IS NULL AS pkey_unmat_after_abort;
+INSERT INTO gtt_lz2 VALUES (1);                    -- no phantom duplicate
+SET enable_seqscan = off;
+SELECT * FROM gtt_lz2 WHERE id = 1;                -- index scan is consistent
+RESET enable_seqscan;
+-- variant: index materialized by a scan in an earlier transaction, then a
+-- write is rolled back; the abort pass empties the stale index
+TRUNCATE gtt_lz2;
+DROP TABLE gtt_lz2;
+CREATE GLOBAL TEMPORARY TABLE gtt_lz3 (id int PRIMARY KEY);
+SET enable_seqscan = off;
+SELECT * FROM gtt_lz3 WHERE id = 9;                -- builds the (empty) index
+RESET enable_seqscan;
+SELECT pg_relation_filepath('gtt_lz3_pkey') IS NOT NULL AS pkey_mat_by_scan;
+BEGIN;
+INSERT INTO gtt_lz3 SELECT generate_series(1, 5);
+ROLLBACK;
+INSERT INTO gtt_lz3 VALUES (2);
+SET enable_seqscan = off;
+SELECT * FROM gtt_lz3 WHERE id = 2;
+RESET enable_seqscan;
+DROP TABLE gtt_lz3;
+
+--
+-- All index access methods behave on unmaterialized GTTs, including the
+-- AMs whose planner support reads index pages (SPGiST's amcanreturn).
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_ams (
+    id int,
+    arr int[],
+    p point,
+    rng int4range
+);
+CREATE INDEX gtt_ams_btree ON gtt_ams (id);
+CREATE INDEX gtt_ams_hash ON gtt_ams USING hash (id);
+CREATE INDEX gtt_ams_gin ON gtt_ams USING gin (arr);
+CREATE INDEX gtt_ams_gist ON gtt_ams USING gist (p);
+CREATE INDEX gtt_ams_spgist ON gtt_ams USING spgist (p);
+CREATE INDEX gtt_ams_brin ON gtt_ams USING brin (id);
+\c -
+SET debug_parallel_query = off;		-- GTT storage probes must run in the leader
+SELECT count(*) FROM gtt_ams;                      -- plans fine, reads nothing
+INSERT INTO gtt_ams VALUES (1, ARRAY[1,2], point(1,1), int4range(1,10));
+SET enable_seqscan = off;
+SELECT id FROM gtt_ams WHERE id = 1;
+SELECT id FROM gtt_ams WHERE arr @> ARRAY[2];
+SELECT id FROM gtt_ams WHERE p <@ box '((0,0),(2,2))';
+RESET enable_seqscan;
+DROP TABLE gtt_ams;
+
+--
+-- DISCARD releases the storage and the DDL hold once it commits: after a
+-- committed DISCARD the session is back to the unmaterialized state.
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_dd2 (x int);
+INSERT INTO gtt_dd2 VALUES (1), (2);
+DISCARD TEMP;
+SELECT pg_relation_filepath('gtt_dd2') IS NULL AS dematerialized;
+SELECT count(*) FROM gtt_dd2;
+-- a rolled-back DISCARD keeps both the data and the storage
+INSERT INTO gtt_dd2 VALUES (3);
+BEGIN;
+DISCARD TEMP;
+ROLLBACK;
+SELECT count(*) AS rows_kept FROM gtt_dd2;
+SELECT pg_relation_filepath('gtt_dd2') IS NOT NULL AS still_materialized;
+-- writing after DISCARD in the same transaction keeps the new data
+BEGIN;
+DISCARD TEMP;
+INSERT INTO gtt_dd2 VALUES (4);
+COMMIT;
+SELECT * FROM gtt_dd2;
+SELECT pg_relation_filepath('gtt_dd2') IS NOT NULL AS kept_for_new_data;
+DROP TABLE gtt_dd2;
+RESET debug_parallel_query;
+
+--
+-- Index lifecycle corner cases found by randomized stress testing
+--
+
+-- 1. CREATE INDEX deferred (heap unmaterialized) in the same transaction
+--    that later materializes the heap: the deferred build must still happen.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc (id int PRIMARY KEY, v int);
+BEGIN;
+CREATE INDEX gtt_ilc_v_idx ON gtt_ilc (v);
+TRUNCATE gtt_ilc;
+INSERT INTO gtt_ilc VALUES (1, 1);
+COMMIT;
+SELECT * FROM gtt_ilc;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT * FROM gtt_ilc WHERE v = 1;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE gtt_ilc;
+
+-- 2. CREATE INDEX built for real (heap materialized), then TRUNCATE swaps
+--    it to a fresh empty file in the same transaction: it must be rebuilt
+--    on next access, not assumed handled by index_create.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc2 (id int PRIMARY KEY, v int);
+INSERT INTO gtt_ilc2 VALUES (1, 1);
+BEGIN;
+CREATE INDEX gtt_ilc2_v_idx ON gtt_ilc2 (v);
+TRUNCATE gtt_ilc2;
+INSERT INTO gtt_ilc2 VALUES (2, 2);
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT * FROM gtt_ilc2 WHERE v = 2;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE gtt_ilc2;
+
+-- 3. Planning a query when an index is materialized but emptied by the
+--    abort pass (heap storage reverted): plan-time metapage readers must
+--    treat it as unbuilt rather than read a zero-block file.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc3 (id int PRIMARY KEY, v int);
+BEGIN;
+INSERT INTO gtt_ilc3 VALUES (1, 1);
+TRUNCATE gtt_ilc3;
+ROLLBACK;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM gtt_ilc3 WHERE id BETWEEN 1 AND 10;  -- scan-builds pkey
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+BEGIN;
+INSERT INTO gtt_ilc3 VALUES (2, 2);
+SAVEPOINT s1;
+TRUNCATE gtt_ilc3;
+ROLLBACK;
+SELECT count(*) FROM gtt_ilc3;  -- planner must survive the emptied pkey
+INSERT INTO gtt_ilc3 VALUES (3, 3);
+SELECT * FROM gtt_ilc3;
+DROP TABLE gtt_ilc3;
+
+-- 4. Nested aborts: a swap-undo from an outer subtransaction must not
+--    resurrect index_built over files the same abort unlinked.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc4 (id int PRIMARY KEY, v int);
+CREATE INDEX gtt_ilc4_v_idx ON gtt_ilc4 (v);
+BEGIN;
+SAVEPOINT s1;
+INSERT INTO gtt_ilc4 VALUES (1, 1);
+ROLLBACK TO SAVEPOINT s1;
+INSERT INTO gtt_ilc4 VALUES (2, 2);
+TRUNCATE gtt_ilc4;
+SAVEPOINT s2;
+INSERT INTO gtt_ilc4 VALUES (3, 3);
+ROLLBACK;
+BEGIN;
+SAVEPOINT s3;
+INSERT INTO gtt_ilc4 VALUES (4, 4);
+COMMIT;
+SELECT * FROM gtt_ilc4;
+DROP TABLE gtt_ilc4;
+
+-- 5. DROP INDEX inside a rolled-back subtransaction must not retire the
+--    session entry at commit (the index is still live); a committed
+--    subtransaction drop must.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc5 (id int PRIMARY KEY, v int);
+INSERT INTO gtt_ilc5 VALUES (1, 1);
+BEGIN;
+CREATE INDEX gtt_ilc5_v_idx ON gtt_ilc5 (v);
+SAVEPOINT s1;
+DROP INDEX gtt_ilc5_v_idx;
+ROLLBACK TO SAVEPOINT s1;
+INSERT INTO gtt_ilc5 VALUES (2, 2);
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT * FROM gtt_ilc5 WHERE v = 2;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+BEGIN;
+SAVEPOINT s2;
+DROP INDEX gtt_ilc5_v_idx;
+RELEASE SAVEPOINT s2;
+COMMIT;
+INSERT INTO gtt_ilc5 VALUES (3, 3);
+SELECT * FROM gtt_ilc5 ORDER BY id;
+DROP TABLE gtt_ilc5;
+
+-- 6. Sequence advancement is non-transactional and must survive the abort
+--    of the transaction that first materialized the per-session sequence;
+--    rolled-back nextval values are not handed out again.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc6
+  (id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, v int);
+BEGIN;
+INSERT INTO gtt_ilc6 (v) VALUES (1);
+ROLLBACK;
+INSERT INTO gtt_ilc6 (v) VALUES (2) RETURNING id;  -- id 2, not 1
+-- TRUNCATE RESTART IDENTITY stays transactional
+BEGIN;
+TRUNCATE gtt_ilc6 RESTART IDENTITY;
+ROLLBACK;
+INSERT INTO gtt_ilc6 (v) VALUES (3) RETURNING id;  -- id 3: restart rolled back
+TRUNCATE gtt_ilc6 RESTART IDENTITY;
+INSERT INTO gtt_ilc6 (v) VALUES (4) RETURNING id;  -- id 1: restart committed
+-- an aborted CREATE still cleans up its sequence file
+BEGIN;
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc6b
+  (id int GENERATED BY DEFAULT AS IDENTITY, v int);
+INSERT INTO gtt_ilc6b (v) VALUES (1);
+ROLLBACK;
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc6b
+  (id int GENERATED BY DEFAULT AS IDENTITY, v int);
+INSERT INTO gtt_ilc6b (v) VALUES (1) RETURNING id;  -- id 1, fresh file
+DROP TABLE gtt_ilc6;
+DROP TABLE gtt_ilc6b;
+
+-- 7. Repeated TRUNCATE in one transaction: the second TRUNCATE must not
+--    take the in-place path, which would touch the (possibly
+--    unmaterialized) toast relation's file directly.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc7 (id int PRIMARY KEY, t text);
+INSERT INTO gtt_ilc7 VALUES (1, 'x');
+BEGIN;
+TRUNCATE gtt_ilc7;
+TRUNCATE gtt_ilc7;
+INSERT INTO gtt_ilc7 VALUES (2, 'y');
+COMMIT;
+SELECT * FROM gtt_ilc7;
+-- and TRUNCATE of a same-transaction-created GTT (rd_createSubid path)
+BEGIN;
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc7b (id int PRIMARY KEY, t text);
+TRUNCATE gtt_ilc7b;
+INSERT INTO gtt_ilc7b VALUES (1, 'x');
+COMMIT;
+SELECT * FROM gtt_ilc7b;
+DROP TABLE gtt_ilc7;
+DROP TABLE gtt_ilc7b;
+
+-- 8. A same-transaction-created index whose storage is reverted by a
+--    subtransaction abort must still be rebuilt when the heap
+--    rematerializes later in the transaction.
+CREATE GLOBAL TEMPORARY TABLE gtt_ilc8 (id int PRIMARY KEY, v int);
+BEGIN;
+CREATE INDEX gtt_ilc8_v ON gtt_ilc8 (v);
+SAVEPOINT s1;
+INSERT INTO gtt_ilc8 VALUES (1, 1);
+ROLLBACK TO SAVEPOINT s1;
+INSERT INTO gtt_ilc8 VALUES (2, 2);
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT * FROM gtt_ilc8 WHERE v = 2;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE gtt_ilc8;
+
+--
+-- Prepared statements with GTT
+--
+CREATE GLOBAL TEMPORARY TABLE gtt_prep (a int, b text);
+INSERT INTO gtt_prep VALUES (1, 'one'), (2, 'two'), (3, 'three');
+PREPARE gtt_prep_q AS SELECT * FROM gtt_prep WHERE a = $1;
+EXECUTE gtt_prep_q(2);
+EXECUTE gtt_prep_q(1);
+DEALLOCATE gtt_prep_q;
+
+-- PREPARE/EXECUTE for CTAS
+PREPARE ctas_prep AS SELECT g, g * 2 AS doubled FROM generate_series(1, 3) g;
+CREATE GLOBAL TEMP TABLE gtt_ctas_exec AS EXECUTE ctas_prep;
+SELECT * FROM gtt_ctas_exec ORDER BY g;
+DROP TABLE gtt_ctas_exec;
+DEALLOCATE ctas_prep;
+
+DROP TABLE gtt_prep;
+
+--
+-- Typed GTT (OF composite type)
+--
+CREATE TYPE gtt_composite AS (a int, b int);
+CREATE GLOBAL TEMP TABLE gtt_typed OF gtt_composite;
+INSERT INTO gtt_typed VALUES (1, 2);
+SELECT * FROM gtt_typed;
+DROP TABLE gtt_typed;
+DROP TYPE gtt_composite;
-- 
2.43.0



  [text/x-patch] 0012-Global-temporary-tables-stress-concurrency-and-crash.patch (24.9K, ../[email protected]/13-0012-Global-temporary-tables-stress-concurrency-and-crash.patch)
  download | inline diff:
From 56e2ed04d271c2bc375f1a40a246d5c5218f8137 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email protected]>
Date: Fri, 12 Jun 2026 19:30:20 -0400
Subject: [PATCH 12/12] Global temporary tables: stress, concurrency, and
 crash-recovery tests

Three TAP tests in test_misc that explore the GTT state space rather
than sample it:

- 015_gtt_stress.pl: a randomized single-session oracle.  A GTT and a
  local temporary table receive identical streams of random DML at
  random savepoint depths with random subtransaction and transaction
  outcomes, plus in-transaction index DDL and VACUUM/ANALYZE; the temp
  table goes through the ordinary transactional storage machinery, so
  after every transaction the two must match -- by sequential scan and
  by forced index scan, the latter validating that per-session index
  entries reference live heap data.  Periodic reconnects exercise the
  fresh-session lazy paths.  Runs are seeded and replayable
  (GTT_STRESS_SEED); GTT_STRESS_XACTS scales the length.
- 016_gtt_concurrency.pl: pgbench with a weighted script mix (savepoint
  DML, ON COMMIT DELETE ROWS, TRUNCATE, DISCARD ALL, reads, DDL against
  a busy table, whole-table create/drop churn) hammering the shared
  sessions registry, its DDL grace loop, and OID recycling under real
  concurrency.  GTT data being session-private lets each script assert
  its own invariants inline.  Afterwards no per-session relation files
  may linger and DDL must succeed instantly; a second phase verifies
  the pooled-connection workflow (DISCARD ALL deregisters, a peer's
  DROP succeeds while the pooled session idles).
- 017_gtt_crash.pl: a SIGKILLed backend (crash-restart cycle) and an
  immediate cluster stop with an open writing transaction.  Orphaned
  per-session files must be removed, the shared definition survives
  empty, the in-memory registry resets so no ghost registration blocks
  DDL, and the table is immediately usable and droppable.

The oracle additionally covers a toasted text column, an identity
column (the GTT sequence checked against the temp table's sequence),
an ON COMMIT DELETE ROWS pair, TRUNCATE RESTART IDENTITY, column DDL,
DISCARD TEMP reset points, and periodic amcheck bt_index_check probes
(heapallindexed included) validating the btree structure against the
heap.
---
 src/test/modules/test_misc/Makefile           |   1 +
 src/test/modules/test_misc/meson.build        |   3 +
 .../modules/test_misc/t/015_gtt_stress.pl     | 333 ++++++++++++++++++
 .../test_misc/t/016_gtt_concurrency.pl        | 206 +++++++++++
 src/test/modules/test_misc/t/017_gtt_crash.pl |  97 +++++
 5 files changed, 640 insertions(+)
 create mode 100644 src/test/modules/test_misc/t/015_gtt_stress.pl
 create mode 100644 src/test/modules/test_misc/t/016_gtt_concurrency.pl
 create mode 100644 src/test/modules/test_misc/t/017_gtt_crash.pl

diff --git a/src/test/modules/test_misc/Makefile b/src/test/modules/test_misc/Makefile
index fedbef071ef..995a8500142 100644
--- a/src/test/modules/test_misc/Makefile
+++ b/src/test/modules/test_misc/Makefile
@@ -3,6 +3,7 @@
 TAP_TESTS = 1
 
 EXTRA_INSTALL=src/test/modules/injection_points \
+	contrib/amcheck \
 	contrib/test_decoding
 
 # The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index 867ee80ff1b..20f8939f344 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -23,6 +23,9 @@ tests += {
       't/012_ddlutils.pl',
       't/013_temp_obj_multisession.pl',
       't/014_gtt_xid_horizon.pl',
+      't/015_gtt_stress.pl',
+      't/016_gtt_concurrency.pl',
+      't/017_gtt_crash.pl',
     ],
     # The injection points are cluster-wide, so disable installcheck
     'runningcheck': false,
diff --git a/src/test/modules/test_misc/t/015_gtt_stress.pl b/src/test/modules/test_misc/t/015_gtt_stress.pl
new file mode 100644
index 00000000000..26516bf897a
--- /dev/null
+++ b/src/test/modules/test_misc/t/015_gtt_stress.pl
@@ -0,0 +1,333 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+# Randomized single-session stress test for global temporary tables.
+#
+# A GTT and a local temporary table receive identical streams of randomly
+# generated DML (inserts with ON CONFLICT -- explicit-id and
+# identity-assigned -- toasted-text writes, updates, deletes, TRUNCATE with
+# and without RESTART IDENTITY) at random savepoint depths, with random
+# ROLLBACK TO / RELEASE / top-level commit-or-rollback outcomes, occasional
+# in-transaction index and column DDL, and periodic DISCARD TEMP.  The local
+# temp table goes through the ordinary transactional storage machinery and
+# therefore serves as an oracle: after every top-level transaction the two
+# tables must have identical contents, both via a plain scan and via a
+# forced index scan, and amcheck must pass on the GTT's primary key
+# (heapallindexed included), validating the btree structure against the
+# heap.  A second ON COMMIT DELETE ROWS pair covers the commit-truncation
+# machinery; an identity column covers per-session GTT sequences (which the
+# RESTART IDENTITY and DISCARD paths reset on both sides identically); a
+# text column exercises the toast lifecycle.  Periodic reconnects exercise
+# the lazy fresh-session paths and backend-exit cleanup.
+#
+# The run is seeded so failures replay: set GTT_STRESS_SEED to the seed
+# reported by a failing run.  GTT_STRESS_XACTS (default 100) scales the run
+# length.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $nxacts = $ENV{GTT_STRESS_XACTS} || 100;
+my $seed =
+  defined $ENV{GTT_STRESS_SEED}
+  ? $ENV{GTT_STRESS_SEED}
+  : int(rand(1 << 30));
+srand($seed);
+diag("gtt stress: seed=$seed transactions=$nxacts");
+
+my $node = PostgreSQL::Test::Cluster->new('gtt_stress');
+$node->init;
+$node->start;
+
+$node->safe_psql(
+	'postgres', q{
+	CREATE EXTENSION amcheck;
+	CREATE GLOBAL TEMPORARY TABLE gtt_s
+		(id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+		 v int, t text);
+	CREATE GLOBAL TEMPORARY TABLE gtt_ocdr_s (x int) ON COMMIT DELETE ROWS;
+});
+
+# A deterministic ~2.5kB incompressible-ish text expression, parameterized
+# by the row counter so both tables compute the same value: large enough to
+# be toasted, exercising the toast lifecycle under the savepoint churn.
+sub bigtext
+{
+	return
+	  "(SELECT string_agg(md5((i * 97 + j)::text), '') FROM generate_series(1, 80) j)";
+}
+
+# The session under test.  The local temp oracles are per-session, so they
+# are recreated on every reconnect (and after DISCARD TEMP); the GTT
+# definitions persist and their data starts empty, matching fresh oracles.
+my $s;
+
+sub create_oracles
+{
+	$s->query_safe(
+		q{CREATE TEMP TABLE ora_s
+			(id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+			 v int, t text);
+		  CREATE TEMP TABLE ora_ocdr_s (x int) ON COMMIT DELETE ROWS;});
+	return;
+}
+
+sub fresh_session
+{
+	$s->quit if defined $s;
+	$s = $node->background_psql('postgres');
+	# IF [NOT] EXISTS notices would otherwise trip query_safe
+	$s->query_safe('SET client_min_messages = warning;');
+	create_oracles();
+	return;
+}
+
+fresh_session();
+
+# Statement log of the current transaction, for replay diagnostics.
+my @xlog;
+
+sub run_sql
+{
+	my $sql = shift;
+	push @xlog, $sql;
+	$s->query_safe($sql);
+	return;
+}
+
+# Apply the same statement shape to a GTT and its oracle.
+sub run_both
+{
+	my $fmt = shift;
+	run_sql(sprintf($fmt, 'gtt_s') . ' ' . sprintf($fmt, 'ora_s'));
+	return;
+}
+
+my $spcount = 0;
+my $bad = 0;
+
+my $contents_sql =
+  q{coalesce(string_agg(id || ':' || v || ':' || coalesce(md5(t), '-'), ',' ORDER BY id), '')};
+
+sub report_mismatch
+{
+	my $why = shift;
+	my $gtt = $s->query_safe("SELECT $contents_sql FROM gtt_s");
+	my $ora = $s->query_safe("SELECT $contents_sql FROM ora_s");
+	diag("MISMATCH ($why) at seed=$seed");
+	diag("transaction statements:\n" . join("\n", @xlog));
+	diag("gtt_s:  $gtt");
+	diag("ora_s:  $ora");
+	$bad = 1;
+	return;
+}
+
+for my $x (1 .. $nxacts)
+{
+	# Reconnect periodically: fresh lazy state against the same definition.
+	fresh_session() if $x % 25 == 0;
+
+	@xlog = ();
+	my @sps = ();
+
+	eval {
+		run_sql('BEGIN;');
+		my $nops = 1 + int(rand(10));
+		for (1 .. $nops)
+		{
+			my $r = rand();
+			my $a = 1 + int(rand(100));
+			my $b = $a + int(rand(20));
+			if ($r < 0.22)
+			{
+				run_both(
+					"INSERT INTO %s (id, v) SELECT i, i / 3 FROM generate_series($a, $b) i ON CONFLICT (id) DO NOTHING;"
+				);
+			}
+			elsif ($r < 0.30)
+			{
+				# identity-assigned ids: both sequences see the same stream
+				run_both(
+					"INSERT INTO %s (v) SELECT i FROM generate_series(1, @{[ 1 + int(rand(5)) ]}) i ON CONFLICT (id) DO NOTHING;"
+				);
+			}
+			elsif ($r < 0.36)
+			{
+				# toasted text payload
+				run_both(
+					"INSERT INTO %s (id, v, t) SELECT i, 0, @{[ bigtext() ]} FROM generate_series($a, $b) i ON CONFLICT (id) DO NOTHING;"
+				);
+			}
+			elsif ($r < 0.46)
+			{
+				run_both(
+					rand() < 0.5
+					? "UPDATE %s SET v = v + 1 WHERE id BETWEEN $a AND $b;"
+					: "UPDATE %s SET t = left(coalesce(t, '') || md5(id::text), 4000) WHERE id BETWEEN $a AND $b;"
+				);
+			}
+			elsif ($r < 0.56)
+			{
+				run_both("DELETE FROM %s WHERE id BETWEEN $a AND $b;");
+			}
+			elsif ($r < 0.61)
+			{
+				run_both(
+					rand() < 0.5
+					? "TRUNCATE %s;"
+					: "TRUNCATE %s RESTART IDENTITY;");
+			}
+			elsif ($r < 0.66)
+			{
+				run_sql(
+					"INSERT INTO gtt_ocdr_s SELECT g FROM generate_series(1, 5) g; "
+					  . "INSERT INTO ora_ocdr_s SELECT g FROM generate_series(1, 5) g;"
+				);
+			}
+			elsif ($r < 0.76)
+			{
+				$spcount++;
+				push @sps, "sp$spcount";
+				run_sql("SAVEPOINT sp$spcount;");
+			}
+			elsif ($r < 0.85 && @sps)
+			{
+				my $k = int(rand(scalar(@sps)));
+				run_sql("ROLLBACK TO SAVEPOINT $sps[$k];");
+				@sps = @sps[ 0 .. $k ];
+			}
+			elsif ($r < 0.90 && @sps)
+			{
+				my $k = int(rand(scalar(@sps)));
+				run_sql("RELEASE SAVEPOINT $sps[$k];");
+				@sps = $k > 0 ? @sps[ 0 .. $k - 1 ] : ();
+			}
+			elsif ($r < 0.94)
+			{
+				# In-transaction index DDL on the GTT only; IF [NOT] EXISTS
+				# keeps this oblivious to surrounding subxact rollbacks.
+				run_sql(
+					rand() < 0.5
+					? 'CREATE INDEX IF NOT EXISTS gtt_s_v_idx ON gtt_s (v);'
+					: 'DROP INDEX IF EXISTS gtt_s_v_idx;');
+			}
+			elsif ($r < 0.97)
+			{
+				# column DDL: same shape on both tables so contents compare
+				run_both(
+					rand() < 0.5
+					? "ALTER TABLE %s ADD COLUMN IF NOT EXISTS extra int DEFAULT 0;"
+					: "ALTER TABLE %s DROP COLUMN IF EXISTS extra;");
+			}
+			else
+			{
+				my $same = $s->query_safe(
+					'SELECT (SELECT count(*) FROM gtt_s) = (SELECT count(*) FROM ora_s);'
+				);
+				if ($same ne 't')
+				{
+					report_mismatch("mid-transaction count, xact $x");
+					last;
+				}
+			}
+		}
+		run_sql(rand() < 0.7 ? 'COMMIT;' : 'ROLLBACK;');
+	};
+	if ($@)
+	{
+		diag("ERROR at seed=$seed, xact $x: $@");
+		diag("transaction statements:\n" . join("\n", @xlog));
+		$bad = 1;
+	}
+	last if $bad;
+
+	# Full-content verification through the default (sequential) path.
+	my $same = $s->query_safe(
+		"SELECT (SELECT $contents_sql FROM gtt_s) = (SELECT $contents_sql FROM ora_s);"
+	);
+	if ($same ne 't')
+	{
+		report_mismatch("post-transaction contents, xact $x");
+		last;
+	}
+
+	# The ON COMMIT DELETE ROWS pair must agree too (both empty after any
+	# top-level transaction end).
+	$same = $s->query_safe(
+		'SELECT (SELECT count(*) FROM gtt_ocdr_s) = (SELECT count(*) FROM ora_ocdr_s)
+		    AND (SELECT count(*) FROM gtt_ocdr_s) = 0;');
+	if ($same ne 't')
+	{
+		report_mismatch("ON COMMIT DELETE ROWS state, xact $x");
+		last;
+	}
+
+	# Range probe through a forced index scan: catches index entries whose
+	# TIDs do not correspond to live oracle-confirmed heap data.
+	my $a = 1 + int(rand(100));
+	my $b = $a + int(rand(30));
+	$s->query_safe('SET enable_seqscan = off; SET enable_bitmapscan = off;');
+	$same = $s->query_safe(
+		"SELECT (SELECT count(*) FROM gtt_s WHERE id BETWEEN $a AND $b)
+		      = (SELECT count(*) FROM ora_s WHERE id BETWEEN $a AND $b);");
+	$s->query_safe('RESET enable_seqscan; RESET enable_bitmapscan;');
+	if ($same ne 't')
+	{
+		report_mismatch("forced index scan BETWEEN $a AND $b, xact $x");
+		last;
+	}
+
+	# Structural verification: the probe above built the pkey if it wasn't
+	# already, so amcheck can read it; heapallindexed additionally proves
+	# heap and index agree.
+	if (rand() < 0.30)
+	{
+		my $heapallindexed = rand() < 0.5 ? 'true' : 'false';
+		$s->query_safe(
+			"SELECT bt_index_check('gtt_s_pkey', $heapallindexed);");
+	}
+
+	# Occasional maintenance commands; they must not change contents.  Use
+	# VACUUM (ANALYZE) rather than bare VACUUM: when the session has no data
+	# the latter emits an INFO, which ignores client_min_messages and would
+	# trip query_safe.
+	if (rand() < 0.15)
+	{
+		$s->query_safe(
+			rand() < 0.5 ? 'VACUUM (ANALYZE) gtt_s;' : 'ANALYZE gtt_s;');
+	}
+
+	# Occasionally release everything: DISCARD TEMP dematerializes the GTT
+	# session data (and identity sequence) and drops the temp oracles; both
+	# sides then restart from the same empty state.
+	if (rand() < 0.05)
+	{
+		$s->query_safe('DISCARD TEMP;');
+		my $empty = $s->query_safe(
+			'SELECT (SELECT count(*) FROM gtt_s) = 0
+			    AND (SELECT count(*) FROM gtt_ocdr_s) = 0;');
+		if ($empty ne 't')
+		{
+			diag("DISCARD TEMP left data behind at seed=$seed, xact $x");
+			$bad = 1;
+			last;
+		}
+		create_oracles();
+	}
+}
+
+ok(!$bad, "$nxacts random transactions matched the oracle (seed $seed)");
+
+$s->quit;
+
+# A peer session must be able to drop the tables once the stress session is
+# gone: no stale registry entry or session file may survive.
+$s = undef;
+$node->safe_psql('postgres', 'DROP TABLE gtt_s; DROP TABLE gtt_ocdr_s;');
+pass('DROP TABLE succeeds after the stress session disconnected');
+
+$node->stop;
+done_testing();
diff --git a/src/test/modules/test_misc/t/016_gtt_concurrency.pl b/src/test/modules/test_misc/t/016_gtt_concurrency.pl
new file mode 100644
index 00000000000..bf8bc72c45a
--- /dev/null
+++ b/src/test/modules/test_misc/t/016_gtt_concurrency.pl
@@ -0,0 +1,206 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+# Concurrent stress test for global temporary tables, in two phases.
+#
+# Phase 1 drives pgbench with a weighted mix of per-session DML (including
+# savepoints, TRUNCATE, ON COMMIT DELETE ROWS, and DISCARD ALL), concurrent
+# DDL against a busy table (expected to be refused with object_in_use), and
+# whole-table create/drop churn.  Because GTT data is session-private, each
+# script can assert its own session's invariants inline (the 1/(cond)::int
+# idiom turns a violated invariant into a division-by-zero error, which
+# aborts the pgbench client and fails the test).  This hammers the shared
+# sessions registry, its 5-second DDL grace loop, OID recycling, and the
+# lazy materialization machinery under real concurrency.
+#
+# Phase 2 simulates a connection pooler: a long-lived session repeatedly
+# writes and then issues DISCARD ALL.  The DISCARD must dematerialize and
+# deregister, so a peer's DROP TABLE succeeds while the pooled session is
+# still connected and idle.
+#
+# After each phase the test verifies that no per-session relation files
+# (t<proc>_<filenode>) linger in the database directory once their sessions
+# are quiesced or gone.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $duration = $ENV{GTT_STRESS_DURATION} || 15;
+
+my $node = PostgreSQL::Test::Cluster->new('gtt_concurrency');
+$node->init;
+$node->start;
+
+$node->safe_psql(
+	'postgres', q{
+	CREATE GLOBAL TEMPORARY TABLE gtt_bench (id int PRIMARY KEY, v int);
+	CREATE GLOBAL TEMPORARY TABLE gtt_ocdr (x int) ON COMMIT DELETE ROWS;
+});
+
+my $dboid = $node->safe_psql('postgres',
+	"SELECT oid FROM pg_database WHERE datname = 'postgres'");
+
+# Count leftover per-session relation files in the database directory.
+sub session_file_count
+{
+	my $dbdir = $node->data_dir . "/base/$dboid";
+	opendir(my $dh, $dbdir) || die "opendir $dbdir: $!";
+	my @files = grep { /^t\d+_/ } readdir($dh);
+	closedir($dh);
+	return scalar(@files);
+}
+
+# pgbench scripts.  All expected errors are caught (object_in_use,
+# duplicate_table for racing DDL); anything else aborts the client.
+my $dir = PostgreSQL::Test::Utils::tempdir_short();
+
+append_to_file(
+	"$dir/dml.sql", q{\set k random(1, 100)
+BEGIN;
+INSERT INTO gtt_bench SELECT i, :k FROM generate_series(:k, :k + 9) i ON CONFLICT (id) DO NOTHING;
+SELECT 1/((count(*) = 10)::int) FROM gtt_bench WHERE id BETWEEN :k AND :k + 9;
+SAVEPOINT s1;
+UPDATE gtt_bench SET v = v + 1 WHERE id = :k;
+ROLLBACK TO SAVEPOINT s1;
+DELETE FROM gtt_bench WHERE id = :k + 5;
+COMMIT;
+});
+
+append_to_file(
+	"$dir/ocdr.sql", q{BEGIN;
+INSERT INTO gtt_ocdr VALUES (1), (2), (3);
+SELECT 1/((count(*) = 3)::int) FROM gtt_ocdr;
+COMMIT;
+SELECT 1/((count(*) = 0)::int) FROM gtt_ocdr;
+});
+
+append_to_file(
+	"$dir/truncate.sql", q{TRUNCATE gtt_bench;
+SELECT 1/((count(*) = 0)::int) FROM gtt_bench;
+INSERT INTO gtt_bench SELECT i, 0 FROM generate_series(1, 20) i ON CONFLICT (id) DO NOTHING;
+});
+
+append_to_file(
+	"$dir/discard.sql",
+	q{INSERT INTO gtt_bench VALUES (-1, 0) ON CONFLICT (id) DO NOTHING;
+DISCARD ALL;
+SELECT 1/((count(*) = 0)::int) FROM gtt_bench;
+});
+
+append_to_file(
+	"$dir/read.sql", q{\set k random(1, 100)
+SELECT count(*) FROM gtt_bench WHERE id = :k;
+EXPLAIN (COSTS OFF) SELECT * FROM gtt_bench WHERE id = :k;
+});
+
+# DDL against the busy table: refused with object_in_use while any peer has
+# session data (after the registry's grace period), so this mostly exercises
+# the refusal/retry path; if it ever wins the race, the index is built and
+# dropped for real.
+append_to_file(
+	"$dir/ddl_busy.sql", q{DO $$
+BEGIN
+	CREATE INDEX gtt_bench_v_idx ON gtt_bench (v);
+	DROP INDEX gtt_bench_v_idx;
+EXCEPTION
+	WHEN object_in_use OR duplicate_table THEN NULL;
+END $$;
+});
+
+# Whole-table churn: create, use, and drop a GTT under concurrency, cycling
+# OIDs and registry entries.  Only the client that won the CREATE inserts
+# and drops.
+append_to_file(
+	"$dir/churn.sql", q{DO $$
+DECLARE
+	created boolean := false;
+BEGIN
+	BEGIN
+		CREATE GLOBAL TEMPORARY TABLE gtt_churn (a int PRIMARY KEY, b text);
+		created := true;
+	EXCEPTION
+		WHEN duplicate_table THEN NULL;
+	END;
+	IF created THEN
+		INSERT INTO gtt_churn VALUES (1, repeat('x', 10));
+		BEGIN
+			DROP TABLE gtt_churn;
+		EXCEPTION
+			WHEN object_in_use THEN NULL;
+		END;
+	END IF;
+END $$;
+});
+
+# Phase 1: run the mix.  --max-tries retries deadlocks (possible between
+# concurrent TRUNCATE/DDL lock acquisitions and ordinary writes); any other
+# error aborts the client and fails the run.
+$node->command_checks_all(
+	[
+		'pgbench', '-n',
+		'-c', '8',
+		'-j', '4',
+		'-T', $duration,
+		'--max-tries', '10',
+		'-f', "$dir/dml.sql\@8",
+		'-f', "$dir/ocdr.sql\@3",
+		'-f', "$dir/truncate.sql\@2",
+		'-f', "$dir/discard.sql\@2",
+		'-f', "$dir/read.sql\@3",
+		'-f', "$dir/ddl_busy.sql\@1",
+		'-f', "$dir/churn.sql\@1",
+		'postgres'
+	],
+	0,
+	[qr/processed/],
+	[qr/^(?!.*aborted)/s],
+	'pgbench concurrent GTT workload runs to completion without aborts');
+
+# Wait for the pgbench backends to fully exit, then verify their session
+# files are gone and nothing blocks DDL.
+$node->poll_query_until('postgres',
+	"SELECT count(*) = 1 FROM pg_stat_activity WHERE backend_type = 'client backend'"
+) or die 'pgbench backends did not exit';
+
+is(session_file_count(), 0,
+	'no per-session relation files survive the pgbench sessions');
+
+$node->safe_psql('postgres',
+	'DROP TABLE gtt_bench; DROP TABLE gtt_ocdr; DROP TABLE IF EXISTS gtt_churn;'
+);
+pass('DROP TABLE succeeds immediately once the stress sessions are gone');
+
+# Phase 2: pooled-connection simulation.
+$node->safe_psql('postgres',
+	'CREATE GLOBAL TEMPORARY TABLE gtt_pool (a int PRIMARY KEY)');
+
+my $pooled = $node->background_psql('postgres');
+for my $i (1 .. 5)
+{
+	$pooled->query_safe(
+		"INSERT INTO gtt_pool SELECT g FROM generate_series(1, 50) g");
+	$pooled->query_safe('DISCARD ALL');
+}
+
+# The pooled session is connected but idle and discarded: a peer's DROP must
+# succeed without waiting on it (a stale registration would error after the
+# 5s grace and fail this safe_psql).
+$node->safe_psql('postgres', 'DROP TABLE gtt_pool');
+pass('DROP TABLE succeeds while a discarded pooled session is still connected'
+);
+
+is(session_file_count(), 0,
+	'DISCARD ALL left no per-session relation files behind');
+
+$pooled->quit;
+
+# No crashes or assertion failures anywhere in the run.
+my $log = slurp_file($node->logfile);
+unlike($log, qr/PANIC|TRAP:/,
+	'no PANIC or assertion failure in the server log');
+
+$node->stop;
+done_testing();
diff --git a/src/test/modules/test_misc/t/017_gtt_crash.pl b/src/test/modules/test_misc/t/017_gtt_crash.pl
new file mode 100644
index 00000000000..9b52aea6968
--- /dev/null
+++ b/src/test/modules/test_misc/t/017_gtt_crash.pl
@@ -0,0 +1,97 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+# Crash-recovery behavior of global temporary tables.  Per-session GTT data
+# is neither WAL-logged nor crash-safe by design; what crash recovery must
+# guarantee is cleanup: orphaned per-session relation files are removed (they
+# follow temporary-relation naming, so remove_temp_files_after_crash and
+# startup cleanup cover them), the shared definition survives, the in-memory
+# sessions registry is reset so no ghost registration blocks DDL, and the
+# table is immediately usable and droppable.  Two crash shapes are covered:
+# a single backend killed with SIGKILL (postmaster crash-restart cycle) and
+# an immediate (simulated crash) shutdown of the whole cluster with an open
+# transaction.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('gtt_crash');
+$node->init;
+# This test locates per-session GTT files with pg_relation_filepath(), which
+# reports the session-local storage of the current backend.  A parallel worker
+# cannot see that session-local storage, so under debug_parallel_query (which
+# CI may bake into the initdb template) the probe would run in a worker and
+# report the wrong path.  Parallelism is irrelevant to crash recovery here, so
+# force it off for the whole test to keep the file-path probes authoritative.
+$node->append_conf(
+	'postgresql.conf', q{
+remove_temp_files_after_crash = on
+restart_after_crash = on
+debug_parallel_query = off
+});
+$node->start;
+
+$node->safe_psql('postgres',
+	'CREATE GLOBAL TEMPORARY TABLE gtt_cr (a int PRIMARY KEY, b text)');
+
+# --- Scenario 1: SIGKILL one backend with materialized GTT storage.
+
+my $s = $node->background_psql('postgres');
+$s->query_safe(
+	"INSERT INTO gtt_cr SELECT g, repeat('x', 100) FROM generate_series(1, 1000) g"
+);
+my $pid = $s->query_safe('SELECT pg_backend_pid()');
+my $heapfile = $s->query_safe("SELECT pg_relation_filepath('gtt_cr')");
+my $idxfile = $s->query_safe("SELECT pg_relation_filepath('gtt_cr_pkey')");
+my $datadir = $node->data_dir;
+
+ok(-e "$datadir/$heapfile", 'per-session heap file exists while in use');
+ok(-e "$datadir/$idxfile", 'per-session index file exists while in use');
+
+kill 'KILL', $pid;
+$s->{run}->finish;    # the killed session is gone; reap it
+
+# The postmaster goes through a crash-restart cycle; wait it out.
+$node->poll_query_until('postgres', 'SELECT true')
+  or die 'node did not recover from backend crash';
+
+ok(!-e "$datadir/$heapfile",
+	'orphaned per-session heap file is removed after a backend crash');
+ok(!-e "$datadir/$idxfile",
+	'orphaned per-session index file is removed after a backend crash');
+
+# The registry lives in shared memory and was reset: nothing may block DDL,
+# and the surviving definition must be immediately usable.
+is($node->safe_psql('postgres', 'SELECT count(*) FROM gtt_cr'),
+	'0', 'GTT definition survives the crash with no data');
+$node->safe_psql('postgres',
+	'ALTER TABLE gtt_cr ADD COLUMN c int; INSERT INTO gtt_cr VALUES (1)');
+pass('DDL and DML work immediately after the crash-restart cycle');
+
+# --- Scenario 2: immediate shutdown with an open writing transaction.
+
+$s = $node->background_psql('postgres');
+$s->query_safe('INSERT INTO gtt_cr VALUES (2)');
+$s->query_safe(
+	'BEGIN; INSERT INTO gtt_cr SELECT g, NULL, NULL FROM generate_series(10, 500) g;'
+);
+$heapfile = $s->query_safe("SELECT pg_relation_filepath('gtt_cr')");
+ok(-e "$datadir/$heapfile", 'per-session heap file exists mid-transaction');
+
+$node->stop('immediate');
+$s->{run}->finish;
+$node->start;
+
+ok( !-e "$datadir/$heapfile",
+	'orphaned per-session heap file is removed by startup after a hard stop');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM gtt_cr'),
+	'0', 'no GTT data survives a cluster crash');
+
+$node->safe_psql('postgres', 'DROP TABLE gtt_cr');
+pass('DROP TABLE succeeds immediately after recovery');
+
+$node->stop;
+done_testing();
-- 
2.43.0



view thread (492+ messages)  latest in thread

reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: [email protected]
  Cc: [email protected], [email protected], [email protected]
  Subject: Re: Global temporary tables
  In-Reply-To: <[email protected]>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox