From 7c04a7b997b6ae185b05cb7987dfb976e990cee5 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
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

