From de67da121b45e603c81739e273d7cb8cfc059fb1 Mon Sep 17 00:00:00 2001 From: Alexander Korotkov Date: Sat, 2 May 2026 15:22:26 +0300 Subject: [PATCH v20 1/2] 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 Author: Daniil Davydov <3danissimo@gmail.com> Reviewed-by: Michael Paquier Reviewed-by: Soumya S Murali Reviewed-by: Tom Lane Reviewed-by: Alexander Korotkov 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.39.5 (Apple Git-154)