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

