diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c index ef14791e2da..9c8ff00f183 100644 --- a/src/backend/storage/buffer/bufmgr.c +++ b/src/backend/storage/buffer/bufmgr.c @@ -1805,9 +1805,23 @@ ExtendBufferedRelCommon(BufferManagerRelation bmr, extend_by); if (bmr.relpersistence == RELPERSISTENCE_TEMP) + { + /* + * Reject attempts to extend non-local temporary relations; we have no + * ability to transfer about-to-be-created local buffers into the + * owning session's local buffers. This is the canonical place for + * the check, covering any attempt to extend a non-local temporary + * relation. + */ + if (bmr.rel && RELATION_IS_OTHER_TEMP(bmr.rel)) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("cannot access temporary tables of other sessions"))); + first_block = ExtendBufferedRelLocal(bmr, fork, flags, extend_by, extend_upto, buffers, &extend_by); + } else first_block = ExtendBufferedRelShared(bmr, fork, strategy, flags, extend_by, extend_upto, 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..df52f7cceca --- /dev/null +++ b/src/test/modules/test_misc/t/013_temp_obj_multisession.pl @@ -0,0 +1,274 @@ +# 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->append_conf('postgresql.conf', 'log_lock_waits=true'); +$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;)); + +# Also create an empty table, so read path go straight through the +# extend-relation entry point. +$psql1->query_safe(q(CREATE TEMP TABLE empty_foo (val INT);)); + +# 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 and are caught by read_stream_begin_impl(). + +$node->psql( + 'postgres', + "SELECT val FROM $tempschema.foo;", + stderr => \$stderr); +like( + $stderr, + qr/cannot access temporary tables of other sessions/, + 'SELECT (seqscan via read_stream)'); + +# INSERT into empty table goes through hio.c which calls RelationAddBlocks() to +# extend the table; that hits the check before new pages are created for the +# table. +$node->psql( + 'postgres', + "INSERT INTO $tempschema.empty_foo VALUES (42);", + stderr => \$stderr); +like( + $stderr, + qr/cannot access temporary tables of other sessions/, + 'INSERT (caught via hio.c)'); + +# 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. +$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); +like($stderr, qr/cannot access temporary tables of other sessions/, 'UPDATE'); + +$node->psql('postgres', "DELETE FROM $tempschema.foo;", stderr => \$stderr); +like($stderr, qr/cannot access temporary tables of other sessions/, 'DELETE'); + +$node->psql( + 'postgres', + "MERGE INTO $tempschema.foo USING (VALUES (42)) AS s(val) " + . "ON foo.val = s.val WHEN MATCHED THEN DELETE;", + stderr => \$stderr); +like($stderr, qr/cannot access temporary tables of other sessions/, 'MERGE'); + +$node->psql('postgres', "COPY $tempschema.foo TO STDOUT;", + stderr => \$stderr); +like($stderr, qr/cannot access temporary tables of other sessions/, 'COPY'); + +# 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 cluster 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); + +# 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'); + +my $foo2_oid = $node->safe_psql('postgres', + "SELECT oid FROM pg_class WHERE relname='foo2';"); + +# Cross-session LOCK TABLE scenario. Ensure that LockRelationOid is working +# properly for other temp tables since this mechanism is also used by +# autovacuum during orphaned tables cleanup. +my $psql2 = $node->background_psql('postgres'); +$psql2->query_safe( + qq{ + BEGIN; + LOCK TABLE $tempschema.foo2 IN ACCESS SHARE MODE; +}); + +# 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. +# Access share lock on the foo2 will block session-exit cleanup, because an +# owner will try to acquire deletion lock all its temp objects via +# findDependentObjects. +my $log_offset = -s $node->logfile; +$psql1->quit; + +# Check whether session-exit cleanup is blocked. +$node->wait_for_log(qr/waiting for AccessExclusiveLock on relation $foo2_oid/, + $log_offset); + +# Release lock on foo2 and allow session-exit cleanup to finish. +$psql2->query_safe(q(COMMIT;)); +$psql2->quit; + +# After releasing the lock, the owner can finally acquire +# AccessExclusiveLock on foo2 and finish session-exit cleanup. Verify +# directly that both foo2 (the locked temp table) and cross_session_func +# (which depended on its row type) have been dropped. Both being gone +# confirms the owner's cleanup got past the blocked findDependentObjects() +# call and completed normally. +$node->poll_query_until('postgres', + "SELECT NOT EXISTS (SELECT 1 FROM pg_class WHERE oid = $foo2_oid)") + or die "foo2 was not cleaned up after owner session exit"; + +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_proc WHERE proname = 'cross_session_func'"), + '0', + 'cross_session_func cleaned up by cascade from foo2'); + +done_testing();