From e3739ee30d6dea0630d56a45e109b0d762cda333 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Sat, 2 May 2026 15:22:26 +0300
Subject: [PATCH v21 1/3] Add tests for cross-session temp table access

Add a TAP test in src/test/modules/test_misc that documents what
happens when one session attempts to read or modify another session's
temporary table.  This commit only adds tests; it does not change
backend behaviour, so the assertions reflect current behaviour:

- SELECT, UPDATE, DELETE, MERGE, COPY on a table without an index
  silently succeed with no error and zero rows / zero affected rows.
  These commands run through the read-stream path, which currently
  bypasses the RELATION_IS_OTHER_TEMP() check.  This is the
  underlying bug to be fixed in a follow-up.
- INSERT errors with "cannot access temporary tables of other
  sessions" because hio.c calls ReadBufferExtended() to find a page
  with free space and is caught by the existing check there.
- Index scan errors via the same existing check, reached through
  nbtree -> ReadBuffer -> ReadBufferExtended.
- TRUNCATE / ALTER TABLE / ALTER INDEX / CLUSTER fail with their
  command-specific error messages.
- VACUUM is silently skipped to avoid noise during database-wide
  VACUUM (vacuum_rel() returns without warning).
- DROP TABLE is intentionally allowed: DROP does not touch the
  table's contents, and autovacuum relies on this to clean up
  temp relations orphaned by a crashed backend.
- ALTER FUNCTION / DROP FUNCTION on an owner-created function over
  its own temp row type work as catalog operations -- they don't
  read the underlying data.
- CREATE FUNCTION from a separate session, using another session's
  temp row type as an argument, is allowed but emits a NOTICE: the
  function is moved into the creator's pg_temp namespace with an
  auto-dependency on the borrowed type, so it disappears together
  with the session that created it.
- A bare DROP TABLE on a temp table that has a cross-session
  dependent function fails with a catalog-level dependency error.
- When the owner session ends, the normal session-exit cleanup
  cascades through DEPENDENCY_NORMAL and removes both the temp
  objects and any cross-session functions that depended on them.

Also document the contract for RELATION_IS_OTHER_TEMP() so that
future buffer-access entry points enforce the same rule.

Author: Jim Jones <jim.jones@uni-muenster.de>
Author: Daniil Davydov <3danissimo@gmail.com>
Reviewed-by: Michael Paquier <michael@paquier.xyz>
Reviewed-by: Soumya S Murali <soumyamurali.work@gmail.com>
Reviewed-by: Tom Lane <tgl@sss.pgh.pa.us>
Reviewed-by: Alexander Korotkov <aekorotkov@gmail.com>
Discussion: https://postgr.es/m/CAJDiXghdFcZ8%3Dnh4G69te7iRr3Q0uFyXxb3ZdG09_GTNZXwH0g%40mail.gmail.com
---
 src/include/utils/rel.h                       |   9 +
 src/test/modules/test_misc/meson.build        |   1 +
 .../test_misc/t/013_temp_obj_multisession.pl  | 235 ++++++++++++++++++
 3 files changed, 245 insertions(+)
 create mode 100644 src/test/modules/test_misc/t/013_temp_obj_multisession.pl

diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index cd1e92f2302..ad50e43b801 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -664,6 +664,15 @@ RelationCloseSmgr(Relation relation)
  * RELATION_IS_OTHER_TEMP
  *		Test for a temporary relation that belongs to some other session.
  *
+ * Any code path that reads a relation's data must reject such relations:
+ * the owning session keeps the data in its private local buffer pool,
+ * which we cannot inspect.  Existing buffer-manager entry points
+ * (ReadBufferExtended(), ReadBuffer_common(), StartReadBuffersImpl(),
+ * read_stream_begin_impl(), PrefetchBuffer()) already enforce this; any
+ * new buffer-access entry point must do the same.  Command-level code
+ * (TRUNCATE, ALTER TABLE, VACUUM, CLUSTER, REINDEX, ...) additionally
+ * uses this macro for command-specific error messages.
+ *
  * Beware of multiple eval of argument
  */
 #define RELATION_IS_OTHER_TEMP(relation) \
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index 356d8454b39..969e90b396d 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -21,6 +21,7 @@ tests += {
       't/010_index_concurrently_upsert.pl',
       't/011_lock_stats.pl',
       't/012_ddlutils.pl',
+      't/013_temp_obj_multisession.pl',
     ],
     # The injection points are cluster-wide, so disable installcheck
     'runningcheck': false,
diff --git a/src/test/modules/test_misc/t/013_temp_obj_multisession.pl b/src/test/modules/test_misc/t/013_temp_obj_multisession.pl
new file mode 100644
index 00000000000..0d211700977
--- /dev/null
+++ b/src/test/modules/test_misc/t/013_temp_obj_multisession.pl
@@ -0,0 +1,235 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Tests that one session cannot read or modify data in another session's
+# temporary table.  Each session keeps its temp data in its own local
+# buffer pool, and a different backend has no visibility into those
+# buffers, so any command that needs to look at the data must be
+# rejected.
+#
+# DROP TABLE is intentionally allowed: it does not touch the table's
+# contents, and autovacuum relies on this to clean up orphaned temp
+# relations left behind by a crashed backend.
+#
+# A regression caught here typically means a new buffer-access entry
+# point bypasses the RELATION_IS_OTHER_TEMP() check.  See
+# ReadBuffer_common(), StartReadBuffersImpl(), and read_stream_begin_impl()
+# for the existing checks.  When adding a new command or buffer-access
+# path, also add a corresponding case below.
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::BackgroundPsql;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('temp_lock');
+$node->init;
+$node->start;
+
+# Owner session.  Created via background_psql so it stays alive while
+# the second session probes its temp objects.
+my $psql1 = $node->background_psql('postgres');
+
+# Initially create the table without an index, so read paths go straight
+# through the read-stream / buffer-manager entry points without being
+# masked by an index scan that would hit ReadBuffer_common from nbtree.
+$psql1->query_safe(q(CREATE TEMP TABLE foo AS SELECT 42 AS val;));
+
+# Resolve the owner's temp schema so the probing session can refer to
+# the table by a fully-qualified name.
+my $tempschema = $node->safe_psql(
+	'postgres',
+	q{
+      SELECT n.nspname
+      FROM pg_class c
+      JOIN pg_namespace n ON n.oid = c.relnamespace
+      WHERE relname = 'foo' AND relpersistence = 't';
+    }
+);
+chomp $tempschema;
+ok($tempschema =~ /^pg_temp_\d+$/, "got temp schema: $tempschema");
+
+my ($stdout, $stderr);
+
+# DML and SELECT have to read the table's data and therefore go through
+# the buffer manager.  With no index on the table, the planner cannot
+# use index access, so SELECT/UPDATE/DELETE/MERGE/COPY all run through
+# the read-stream path.
+#
+# XXX: in current code, the read-stream path bypasses the
+# RELATION_IS_OTHER_TEMP() check, so these commands silently see no
+# rows / report zero affected rows -- the visible symptom of the bug
+# this test suite documents.  A follow-up patch will route the check
+# through read_stream_begin_impl() and these assertions will be
+# updated to expect "cannot access temporary tables of other sessions".
+
+$node->psql(
+	'postgres',
+	"SELECT val FROM $tempschema.foo;",
+	stdout => \$stdout,
+	stderr => \$stderr);
+is($stderr, '', 'SELECT (currently no error -- bug to be fixed)');
+
+# INSERT goes through hio.c which calls ReadBufferExtended() to find a
+# page with free space; that hits the existing check before any data is
+# written.  This case currently errors as expected.
+$node->psql(
+	'postgres',
+	"INSERT INTO $tempschema.foo VALUES (73);",
+	stderr => \$stderr);
+like($stderr,
+	qr/cannot access temporary tables of other sessions/,
+	'INSERT (caught via hio.c)');
+
+$node->psql(
+	'postgres',
+	"UPDATE $tempschema.foo SET val = NULL;",
+	stderr => \$stderr);
+is($stderr, '', 'UPDATE (currently no error -- bug to be fixed)');
+
+$node->psql('postgres', "DELETE FROM $tempschema.foo;", stderr => \$stderr);
+is($stderr, '', 'DELETE (currently no error -- bug to be fixed)');
+
+$node->psql(
+	'postgres',
+	"MERGE INTO $tempschema.foo USING (VALUES (42)) AS s(val) "
+	  . "ON foo.val = s.val WHEN MATCHED THEN DELETE;",
+	stderr => \$stderr);
+is($stderr, '', 'MERGE (currently no error -- bug to be fixed)');
+
+$node->psql('postgres', "COPY $tempschema.foo TO STDOUT;",
+	stderr => \$stderr);
+is($stderr, '', 'COPY (currently no error -- bug to be fixed)');
+
+# DDL and maintenance commands have their own command-specific checks
+# (older than the buffer-manager check above), so they fail with
+# command-specific error messages.  Verifying them here documents the
+# expected behaviour and guards against accidental removal of those
+# checks.
+
+$node->psql('postgres', "TRUNCATE TABLE $tempschema.foo;",
+	stderr => \$stderr);
+like($stderr,
+	qr/cannot truncate temporary tables of other sessions/,
+	'TRUNCATE');
+
+$node->psql(
+	'postgres',
+	"ALTER TABLE $tempschema.foo ALTER COLUMN val TYPE bigint;",
+	stderr => \$stderr);
+like($stderr,
+	qr/cannot alter temporary tables of other sessions/,
+	'ALTER TABLE');
+
+# VACUUM silently skips other sessions' temp tables (vacuum_rel() returns
+# without warning to avoid noise during database-wide VACUUM).  Verify
+# that no error is reported, and that no buffer-access path is hit.
+$node->psql('postgres', "VACUUM $tempschema.foo;", stderr => \$stderr);
+is($stderr, '', 'VACUUM is silently skipped');
+
+$node->psql('postgres', "CLUSTER $tempschema.foo;", stderr => \$stderr);
+like($stderr,
+	qr/cannot execute CLUSTER on temporary tables of other sessions/,
+	'CLUSTER');
+
+# Now create an index to exercise the index-scan path.  nbtree calls
+# ReadBuffer (which is ReadBufferExtended -> ReadBuffer_common), so
+# this exercises a different chain of buffer-manager entry points.
+$psql1->query_safe(q(CREATE INDEX ON foo(val);));
+
+$node->psql(
+	'postgres',
+	"SET enable_seqscan = off; SELECT val FROM $tempschema.foo WHERE val = 42;",
+	stderr => \$stderr);
+like(
+	$stderr,
+	qr/cannot access temporary tables of other sessions/,
+	'index scan (ReadBuffer_common via nbtree)');
+
+# ALTER INDEX goes through the same CheckAlterTableIsSafe() path as
+# ALTER TABLE, so it produces the same error.
+$node->psql(
+	'postgres',
+	"ALTER INDEX $tempschema.foo_val_idx SET (fillfactor = 50);",
+	stderr => \$stderr);
+like($stderr,
+	qr/cannot alter temporary tables of other sessions/,
+	'ALTER INDEX');
+
+# A function created by the owner in its own pg_temp using its own
+# row type can be observed via the catalog by a separate session.
+# ALTER FUNCTION and DROP FUNCTION on it must work as catalog
+# operations -- they don't read the underlying table -- which
+# documents the boundary between catalog and data access for temp
+# objects.
+$psql1->query_safe(
+	q[CREATE FUNCTION pg_temp.foo_id(r foo) RETURNS int LANGUAGE SQL ]
+	  . q[AS 'SELECT r.val';]);
+
+$node->psql(
+	'postgres',
+	"ALTER FUNCTION $tempschema.foo_id($tempschema.foo) "
+	  . "SET search_path = pg_catalog;",
+	stderr => \$stderr);
+is($stderr, '', 'ALTER FUNCTION on function over other session\'s row type');
+
+$node->psql(
+	'postgres',
+	"DROP FUNCTION $tempschema.foo_id($tempschema.foo);",
+	stderr => \$stderr);
+is($stderr, '', 'DROP FUNCTION on function over other session\'s row type');
+
+# DROP TABLE on another session's temp table is intentionally permitted.
+# DROP doesn't touch the table's contents, and autovacuum relies on this
+# to remove temp relations orphaned by a crashed backend.  Verify that
+# the bare DROP succeeds without error.
+$node->psql('postgres', "DROP TABLE $tempschema.foo;", stderr => \$stderr);
+is($stderr, '', 'DROP TABLE is allowed');
+
+# Cross-session CREATE FUNCTION scenario.  The owner creates a fresh
+# temp table foo2 in its pg_temp namespace, and a separate session
+# then creates a function whose argument type is that row type.
+# PostgreSQL allows this and emits a NOTICE: the function is moved
+# into the creator's pg_temp namespace with an auto-dependency on
+# the borrowed type, so it disappears together with the session that
+# created it.
+$psql1->query_safe(q(CREATE TEMP TABLE foo2 AS SELECT 42 AS val;));
+
+$node->psql(
+	'postgres',
+	"CREATE FUNCTION public.cross_session_func(r $tempschema.foo2) "
+	  . "RETURNS int LANGUAGE SQL AS 'SELECT 1';",
+	stderr => \$stderr);
+like(
+	$stderr,
+	qr/function "cross_session_func" will be effectively temporary/,
+	'CREATE FUNCTION using other session\'s row type is effectively temporary'
+);
+
+# A bare DROP TABLE on foo2 now fails because cross_session_func
+# depends on its row type.  This is normal SQL dependency behaviour
+# and documents that DROP itself is not blocked by buffer-manager
+# checks -- we get a catalog-level error instead.
+$node->psql('postgres', "DROP TABLE $tempschema.foo2;", stderr => \$stderr);
+like(
+	$stderr,
+	qr/cannot drop table .*\.foo2 because other objects depend on it/,
+	'DROP TABLE blocked by cross-session dependency');
+
+# When the owner session ends, its temp objects are dropped via the
+# normal session-exit cleanup, which cascades through
+# DEPENDENCY_NORMAL and also removes the cross-session function that
+# depended on the temp row type.  This is the same mechanism
+# autovacuum relies on to clean up temp relations left behind by a
+# crashed backend.
+$psql1->quit;
+
+$node->poll_query_until(
+	'postgres',
+	"SELECT NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'cross_session_func')"
+) or die "cross_session_func was not cleaned up after owner session exit";
+
+ok(1, 'cross_session_func cleaned up when owner session ends');
+
+done_testing();
-- 
2.43.0

