0011-Global-temporary-tables-regression-tests-and-documen.patch
text/x-patch
Filename: 0011-Global-temporary-tables-regression-tests-and-documen.patch
Type: text/x-patch
Part: 10
Message:
Re: Global temporary tables
Patch
Format: format-patch
Series: patch 0011
Subject: Global temporary tables: regression tests and documentation
| File | + | − |
|---|---|---|
| doc/src/sgml/config.sgml | 33 | 0 |
| doc/src/sgml/func/func-admin.sgml | 27 | 0 |
| doc/src/sgml/ref/create_sequence.sgml | 16 | 1 |
| doc/src/sgml/ref/create_table.sgml | 167 | 19 |
| doc/src/sgml/ref/discard.sgml | 3 | 1 |
| src/bin/pg_dump/t/002_pg_dump.pl | 29 | 1 |
| src/test/isolation/expected/global-temp-table.out | 125 | 0 |
| src/test/isolation/isolation_schedule | 1 | 0 |
| src/test/isolation/specs/global-temp-table.spec | 148 | 0 |
| src/test/modules/test_misc/meson.build | 1 | 0 |
| src/test/modules/test_misc/t/014_gtt_xid_horizon.pl | 132 | 0 |
| src/test/regress/expected/global_temp.out | 2600 | 0 |
| src/test/regress/parallel_schedule | 5 | 0 |
| src/test/regress/sql/global_temp.sql | 1548 | 0 |
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