From 56e2ed04d271c2bc375f1a40a246d5c5218f8137 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Fri, 12 Jun 2026 19:30:20 -0400
Subject: [PATCH 12/12] Global temporary tables: stress, concurrency, and
 crash-recovery tests

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

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

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

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

