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

