diff --git a/src/backend/utils/adt/ddlutils.c b/src/backend/utils/adt/ddlutils.c index d83cda33..b57e7d27 100644 --- a/src/backend/utils/adt/ddlutils.c +++ b/src/backend/utils/adt/ddlutils.c @@ -35,6 +35,7 @@ #include "utils/acl.h" #include "utils/array.h" #include "utils/builtins.h" +#include "utils/injection_point.h" #include "utils/datetime.h" #include "utils/fmgroids.h" #include "utils/guc.h" @@ -984,9 +985,13 @@ pg_get_database_ddl_internal(Oid dbid, bool pretty, /* TABLESPACE */ if (!no_tablespace && OidIsValid(dbform->dattablespace)) { - char *spcname = get_tablespace_name(dbform->dattablespace); + char *spcname; - if (pg_strcasecmp(spcname, "pg_default") != 0) + INJECTION_POINT("pg_get_database_ddl-before-get_tablespace_name", NULL); + spcname = get_tablespace_name(dbform->dattablespace); + + if (spcname != NULL && + pg_strcasecmp(spcname, "pg_default") != 0) append_ddl_option(&buf, pretty, 4, "TABLESPACE = %s", quote_identifier(spcname)); } diff --git a/src/test/modules/test_misc/t/012_ddlutils_tablespace_race.pl b/src/test/modules/test_misc/t/012_ddlutils_tablespace_race.pl new file mode 100644 index 00000000..76d31afb --- /dev/null +++ b/src/test/modules/test_misc/t/012_ddlutils_tablespace_race.pl @@ -0,0 +1,111 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test for race condition in pg_get_database_ddl() when a tablespace +# is dropped concurrently. +# +# pg_get_database_ddl() reads the database's dattablespace OID from the +# syscache, then later calls get_tablespace_name() which does a fresh +# catalog scan. If the tablespace is dropped between these two operations, +# get_tablespace_name() returns NULL and the unpatched code passes NULL +# to pg_strcasecmp(), causing a SIGSEGV. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; + +# Node initialization +my $node = PostgreSQL::Test::Cluster->new('node'); +$node->init(); +$node->append_conf('postgresql.conf', + "shared_preload_libraries = 'injection_points'"); +$node->start(); + +# Check if the extension injection_points is available +plan skip_all => 'Extension injection_points not installed' + unless $node->check_extension('injection_points'); + +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;'); + +# Create tablespace and database using it +$node->safe_psql('postgres', q[ +SET allow_in_place_tablespaces = on; +CREATE TABLESPACE race_ts LOCATION ''; +]); +$node->safe_psql('postgres', + "CREATE DATABASE race_db TABLESPACE race_ts;"); + +# Verify setup +my $result = $node->safe_psql('postgres', + "SELECT spcname FROM pg_database d JOIN pg_tablespace t " + . "ON d.dattablespace = t.oid WHERE datname = 'race_db';"); +is($result, 'race_ts', 'race_db is on race_ts tablespace'); + +############################################################################ +note('Test: pg_get_database_ddl with concurrent tablespace drop'); + +# Attach injection point that pauses before get_tablespace_name() +$node->safe_psql('postgres', + "SELECT injection_points_attach(" + . "'pg_get_database_ddl-before-get_tablespace_name', 'wait');"); + +# Session A: call pg_get_database_ddl - will block at injection point +my $session_a = $node->background_psql('postgres'); + +$session_a->query_until( + qr//, q[ +SELECT pg_get_database_ddl('race_db'::regdatabase); +\g +]); + +# Wait for Session A to reach the injection point +$node->wait_for_event('client backend', + 'pg_get_database_ddl-before-get_tablespace_name'); + +note('Session A is paused before get_tablespace_name()'); + +# Session B: move database off the tablespace and drop it +$node->safe_psql('postgres', + "ALTER DATABASE race_db SET TABLESPACE pg_default;"); +$node->safe_psql('postgres', "DROP TABLESPACE race_ts;"); + +# Verify the tablespace is gone +$result = $node->safe_psql('postgres', + "SELECT count(*) FROM pg_tablespace WHERE spcname = 'race_ts';"); +is($result, '0', 'tablespace race_ts has been dropped'); + +note('Waking up Session A - get_tablespace_name() will return NULL'); + +# Wake up Session A - it will now call get_tablespace_name() with a +# stale OID that no longer exists. Without the NULL check fix, this +# crashes with SIGSEGV in pg_strcasecmp(NULL, "pg_default"). +$node->safe_psql('postgres', + "SELECT injection_points_wakeup(" + . "'pg_get_database_ddl-before-get_tablespace_name');"); + +# Collect result from Session A +my $output = $session_a->query_safe(q[\g]); +$session_a->quit(); + +# The DDL should succeed and NOT include TABLESPACE (since the database +# is now on pg_default) +like($output, qr/CREATE DATABASE race_db/, + 'pg_get_database_ddl completed without crash'); +unlike($output, qr/TABLESPACE/, + 'DDL output does not include dropped tablespace'); + +# Detach injection point +$node->safe_psql('postgres', + "SELECT injection_points_detach(" + . "'pg_get_database_ddl-before-get_tablespace_name');"); + +# Clean up +$node->safe_psql('postgres', "DROP DATABASE race_db;"); + +done_testing();