Subject: Re: pg_upgrade fails when extension_control_path is used

From: "Jonathan Gonzalez V." <[email-masked]>
Date: Thu, 05 Mar 2026 12:36:36 +0100

Hello,

I'm attaching a v2 with rebase and adding Niccolo Fei as one of the
reviewers.

Thank you!

-- 
Jonathan Gonzalez V. <[email-masked]>
EnterpriseDB
From fef92b2baa38edc0d8c545e3fe60a5803268864a Mon Sep 17 00:00:00 2001
From: "Jonathan Gonzalez V." <[email-masked]>
Date: Mon, 23 Feb 2026 22:27:51 +0100
Subject: [PATCH v2 1/1] Strip `$libdir` during pg_upgrade starting on 19.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

With the commit 4f7f7b03758 extension_control_path GUC was included,
while some test were added, is missing to handle the hardcoded `$libdir/`
path during the execution of `pg_upgrade` for the the installed
extensions using the extension_control_path GUC

An aditional test for `pg_upgrade` is added to test the upgrade with
the extension_control_path in use with a C extension using the
hardcoded `$libdir/` string in the `module_pathname`

Reviewed-by: NiccolÃ² Fei <[email-masked]>
Signed-off-by: Jonathan Gonzalez V. <[email-masked]>
---
 src/bin/pg_upgrade/Makefile                   |   6 +-
 src/bin/pg_upgrade/function.c                 |   9 ++
 src/bin/pg_upgrade/meson.build                |  21 ++-
 .../t/008_extension_control_path.pl           | 126 ++++++++++++++++++
 src/test/modules/test_extensions/Makefile     |   3 +
 src/test/modules/test_extensions/meson.build  |  13 ++
 src/test/modules/test_extensions/test_ext.c   |  22 +++
 7 files changed, 198 insertions(+), 2 deletions(-)
 create mode 100644 src/bin/pg_upgrade/t/008_extension_control_path.pl
 create mode 100644 src/test/modules/test_extensions/test_ext.c

diff --git a/src/bin/pg_upgrade/Makefile b/src/bin/pg_upgrade/Makefile
index 726df4b7525..771addb675a 100644
--- a/src/bin/pg_upgrade/Makefile
+++ b/src/bin/pg_upgrade/Makefile
@@ -3,7 +3,7 @@
 PGFILEDESC = "pg_upgrade - an in-place binary upgrade utility"
 PGAPPICON = win32
 
-EXTRA_INSTALL=contrib/test_decoding src/test/modules/dummy_seclabel
+EXTRA_INSTALL=contrib/test_decoding src/test/modules/dummy_seclabel src/test/modules/test_extensions
 
 subdir = src/bin/pg_upgrade
 top_builddir = ../../..
@@ -38,6 +38,10 @@ LDFLAGS_INTERNAL += -L$(top_builddir)/src/fe_utils -lpgfeutils $(libpq_pgport)
 REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
 export REGRESS_SHLIB
 
+# required for 008_extension_control_path.pl
+TEST_EXT_LIB=$(abs_top_builddir)/src/test/modules/test_extensions/test_ext$(DLSUFFIX)
+export TEST_EXT_LIB
+
 all: pg_upgrade
 
 pg_upgrade: $(OBJS) | submake-libpq submake-libpgport submake-libpgfeutils
diff --git a/src/bin/pg_upgrade/function.c b/src/bin/pg_upgrade/function.c
index a3184f95665..461b7b2c20c 100644
--- a/src/bin/pg_upgrade/function.c
+++ b/src/bin/pg_upgrade/function.c
@@ -120,6 +120,15 @@ get_loadable_libraries(void)
 		{
 			char	   *lib = PQgetvalue(res, rowno, 0);
 
+			/*
+			 * Starting on version 18, the extension may be loaded using
+			 * extension_control_path, some extensions have the `$libdir/`
+			 * hardcoded, we should remove it to allow the upgrade to version
+			 * 19 or above.
+			 */
+			if (strncmp(lib, "$libdir/", 8) == 0)
+				lib += 8;
+
 			os_info.libraries[totaltups].name = pg_strdup(lib);
 			os_info.libraries[totaltups].dbnum = dbnum;
 
diff --git a/src/bin/pg_upgrade/meson.build b/src/bin/pg_upgrade/meson.build
index 49b1b624f25..ffbf6ae8d75 100644
--- a/src/bin/pg_upgrade/meson.build
+++ b/src/bin/pg_upgrade/meson.build
@@ -36,13 +36,30 @@ pg_upgrade = executable('pg_upgrade',
 )
 bin_targets += pg_upgrade
 
+test_ext_sources = files(
+    '../../test/modules/test_extensions/test_ext.c'
+)
+
+if host_system == 'windows'
+  test_ext_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_ext',
+    '--FILEDESC', 'test_ext - test C extension for pg_upgrade',])
+endif
+
+test_ext = shared_module('test_ext',
+  test_ext_sources,
+  kwargs: pg_test_mod_args,
+)
 
 tests += {
   'name': 'pg_upgrade',
   'sd': meson.current_source_dir(),
   'bd': meson.current_build_dir(),
   'tap': {
-    'env': {'with_icu': icu.found() ? 'yes' : 'no'},
+    'env': {
+      'with_icu': icu.found() ? 'yes' : 'no',
+      'TEST_EXT_LIB': test_ext.full_path(),
+    },
     'tests': [
       't/001_basic.pl',
       't/002_pg_upgrade.pl',
@@ -51,7 +68,9 @@ tests += {
       't/005_char_signedness.pl',
       't/006_transfer_modes.pl',
       't/007_multixact_conversion.pl',
+      't/008_extension_control_path.pl',
     ],
+    'deps': [test_ext],
     'test_kwargs': {'priority': 40}, # pg_upgrade tests are slow
   },
 }
diff --git a/src/bin/pg_upgrade/t/008_extension_control_path.pl b/src/bin/pg_upgrade/t/008_extension_control_path.pl
new file mode 100644
index 00000000000..b50e92326ca
--- /dev/null
+++ b/src/bin/pg_upgrade/t/008_extension_control_path.pl
@@ -0,0 +1,126 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test pg_upgrade with the extension_control_path GUC active.
+
+use strict;
+use warnings FATAL => 'all';
+
+use File::Copy;
+use File::Path;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Make sure the extension file .so path is provided
+my $ext_lib_so = $ENV{TEST_EXT_LIB}
+	or die "couldn't get the extension so path";
+
+# Create the custom extension directory layout:
+#   $ext_dir/extension/  -- .control and .sql files
+#   $ext_dir/lib/        -- .so file
+my $ext_dir = PostgreSQL::Test::Utils::tempdir();
+mkpath("$ext_dir/extension");
+mkpath("$ext_dir/lib");
+my $ext_lib = $ext_dir . '/lib';
+
+# Copy the .so file into the lib/ subdirectory.
+copy($ext_lib_so, $ext_lib)
+  or die "could not copy '$ext_lib_so' to '$ext_lib': $!";
+
+create_extension_files('test_ext', $ext_dir);
+
+my $sep = $windows_os ? ";" : ":";
+my $ext_path = $windows_os ? ($ext_dir =~ s/\\/\\\\/gr) : $ext_dir;
+my $ext_lib_path = $windows_os ? ($ext_lib =~ s/\\/\\\\/gr) : $ext_lib;
+
+my $extension_control_path_conf = qq(
+extension_control_path = '\$system$sep$ext_path'
+dynamic_library_path = '\$libdir$sep$ext_lib_path'
+);
+
+my $old =
+  PostgreSQL::Test::Cluster->new('old', install_path => $ENV{oldinstall});
+$old->init;
+
+# Configure extension_control_path so the .control file is found in our
+# extension/ directory, and dynamic_library_path so the .so is found in lib/.
+$old->append_conf('postgresql.conf', $extension_control_path_conf);
+
+$old->start;
+
+# CREATE EXTENSION 'test_ext'
+$old->safe_psql('postgres', 'CREATE EXTENSION test_ext');
+
+# Verify the extension works before the upgrade.
+my ($code, $stdout, $stderr) = $old->psql('postgres', 'SELECT test_ext()');
+is($code, 0, 'extension works before upgrade');
+like($stderr, qr/NOTICE:  running successful/, 'extension working');
+
+$old->stop;
+
+my $new = PostgreSQL::Test::Cluster->new('new');
+$new->init;
+
+# Pre-configure the new cluster with dynamic_library_path and
+# extension_control_path before running pg_upgrade.
+$new->append_conf('postgresql.conf', $extension_control_path_conf);
+
+# In a VPATH build, we'll be started in the source directory, but we want
+# to run pg_upgrade in the build directory so that any files generated finish
+# in it, like delete_old_cluster.{sh,bat}.
+chdir ${PostgreSQL::Test::Utils::tmp_check};
+
+command_ok(
+	[
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $old->data_dir,
+		'--new-datadir' => $new->data_dir,
+		'--old-bindir' => $old->config_data('--bindir'),
+		'--new-bindir' => $new->config_data('--bindir'),
+		'--socketdir' => $new->host,
+		'--old-port' => $old->port,
+		'--new-port' => $new->port,
+		'--copy',
+	],
+	'pg_upgrade succeeds with extension installed via extension_control_path'
+);
+
+$new->start;
+
+# Verify the extension still works after the upgrade.
+($code, $stdout, $stderr) = $new->psql('postgres', 'SELECT test_ext()');
+is($code, 0, 'extension works after upgrade');
+like($stderr, qr/NOTICE:  running successful/, 'extension working');
+
+$new->stop;
+
+# Write .control and .sql files into $ext_dir/extension/
+# `module_pathname` contains the `$libdir/` to simulate most of the extensions
+# that use it as a prefix in the `module_pathname` by default
+sub create_extension_files
+{
+	my ($ext_name, $ext_dir) = @_;
+
+	open my $cf, '>', "$ext_dir/extension/$ext_name.control"
+	  or die "could not create control file: $!";
+	print $cf
+	  "comment = 'Test C extension for pg_upgrade + extension_control_path'\n";
+	print $cf "default_version = '1.0'\n";
+	print $cf "module_pathname = '\$libdir/$ext_name'\n";
+	print $cf "relocatable = true\n";
+	close $cf;
+
+	open my $sqlf, '>', "$ext_dir/extension/$ext_name--1.0.sql"
+	  or die "could not create SQL file: $!";
+	print $sqlf "/* $ext_name--1.0.sql */\n";
+	print $sqlf
+	  "-- complain if script is sourced in psql, rather than via CREATE EXTENSION\n";
+	print $sqlf
+	  qq'\\echo Use "CREATE EXTENSION $ext_name" to load this file. \\quit\n';
+	print $sqlf "CREATE FUNCTION test_ext()\n";
+	print $sqlf "RETURNS void AS 'MODULE_PATHNAME'\n";
+	print $sqlf "LANGUAGE C;\n";
+	close $sqlf;
+}
+
+done_testing();
diff --git a/src/test/modules/test_extensions/Makefile b/src/test/modules/test_extensions/Makefile
index a3591bf3d2f..d1b0b81e5fd 100644
--- a/src/test/modules/test_extensions/Makefile
+++ b/src/test/modules/test_extensions/Makefile
@@ -1,6 +1,7 @@
 # src/test/modules/test_extensions/Makefile
 
 MODULE = test_extensions
+MODULE_big = test_ext
 PGFILEDESC = "test_extensions - regression testing for EXTENSION support"
 
 EXTENSION = test_ext1 test_ext2 test_ext3 test_ext4 test_ext5 test_ext6 \
@@ -11,6 +12,8 @@ EXTENSION = test_ext1 test_ext2 test_ext3 test_ext4 test_ext5 test_ext6 \
             test_ext_set_schema \
             test_ext_req_schema1 test_ext_req_schema2 test_ext_req_schema3
 
+OBJS = test_ext.o
+
 DATA = test_ext1--1.0.sql test_ext2--1.0.sql test_ext3--1.0.sql \
        test_ext4--1.0.sql test_ext5--1.0.sql test_ext6--1.0.sql \
        test_ext7--1.0.sql test_ext7--1.0--2.0.sql \
diff --git a/src/test/modules/test_extensions/meson.build b/src/test/modules/test_extensions/meson.build
index be9c9ae593f..2c7cea189e2 100644
--- a/src/test/modules/test_extensions/meson.build
+++ b/src/test/modules/test_extensions/meson.build
@@ -46,6 +46,19 @@ test_install_data += files(
   'test_ext_set_schema.control',
 )
 
+test_ext_sources = files('test_ext.c')
+
+if host_system == 'windows'
+  test_ext_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_ext',
+    '--FILEDESC', 'test_ext - test C extension for pg_upgrade',])
+endif
+
+test_ext = shared_module('test_ext',
+  test_ext_sources,
+  kwargs: pg_test_mod_args,
+)
+
 tests += {
   'name': 'test_extensions',
   'sd': meson.current_source_dir(),
diff --git a/src/test/modules/test_extensions/test_ext.c b/src/test/modules/test_extensions/test_ext.c
new file mode 100644
index 00000000000..a23165ba67a
--- /dev/null
+++ b/src/test/modules/test_extensions/test_ext.c
@@ -0,0 +1,22 @@
+/*
+ * test_ext.c
+ *
+ * Dummy C extension for testing extension_control_path with pg_upgrade
+ *
+ * Portions Copyright (c) 2026, PostgreSQL Global Development Group
+ */
+#include "postgres.h"
+
+#include "fmgr.h"
+
+PG_MODULE_MAGIC;
+
+PG_FUNCTION_INFO_V1(test_ext);
+
+Datum
+test_ext(PG_FUNCTION_ARGS)
+{
+	ereport(NOTICE,
+			(errmsg("running successful")));
+	PG_RETURN_VOID();
+}
-- 
2.51.0

---

From: Matheus Alcantara <[email-masked]>
Date: Thu, 5 Mar 2026 12:12:14 -0300

Hi,

On 03/03/26 05:15, Jonathan Gonzalez V. wrote:
> The upgrade process from 18 to 19devel using `extension_control_path`
> fails due to some extensions having the `$libdir/` string hard coded in
> the `module_pathname`.
> 
> The finding was done by Niccolo Fei while working on the images
> extension in CloudNativePG, this feature relies on the
> `extension_control_path` GUC that was included in PostgreSQL 18 to
> allow having extension in small images. This is done by having a
> directory layer with two main paths, one for the extension `.sql` and
> `.control` files, and another for the `.so` files or any file that
> requires to be added into the `dynamic_library_path`. These two things
> trigger the bug during the upgrade.
> 

> The problem may look simple and like it can be reproduced with any lib
> that is added to the `dynamic_library_path`,

What do you mean by this? Manually executing LOAD '$libdir/foo' will 
fail if the lib "foo" does not exists on $libdir and this is an 
expected behavior since the user is forcing to load the lib from 
$libdir path. If user execute LOAD 'foo' it will search on 
dynamic_library_path along with extension_control_path. See the commit 
message of f777d773878.

> but the problem is due to
> `pg_upgrade`. Using the `probin` field from `pg_catalog.pg_proc` as the
> string used to pass to the `LOAD` instruction when loading the
> extension again during the upgrade process gives the following error:
> 
> could not load library "$libdir/test_ext": ERROR:  could not access
> file "$libdir/test_ext": No such file or directory
> 
> This problem was already difficult to explain so I decided to create a
> test for this, and the error above is the result of the test without
> the patch to the `function.c` file.
> 
> The patch uses pretty much the same technique that was used in the
> `extenion_control_path` patch, that it removes the `$libdir/` string
> when the name of the extension is required.
> 

I was not fully happy with the strip hack on extension_control_path on 
the time and I have the same felling here but I don't think that we 
have another way to fix this issue, at lest I couldn't think it.

The patch seems correct to me because we don't want to have this on 
LOAD command itself since the user may want to load an extension from 
$libdir so the strip logic on pg_upgrade is more appropriate.

I'm wondering if we need to handle nested paths on pg_upgrade in the 
same way that we handle on load_external_function(), I think that is 
not needed but it can be good to double check.

One minor comment is that the code comment on function.c can be 
similar to what we have on load_external_function():

  /*
  * For extensions with hardcoded '$libdir/' library names, we
  * strip the prefix to allow the library search path to be used.
  */

> The implementation of the test is something that I would like to get
> help on, I moved the location a couple of times to arrive to this one
> because it didn't fit in any other place. Also, it makes it easier to
> implement the `make` support for it, but I'm still having doubts if
> it's the right place.
> 

I personally don't see any issue on keeping the new TAP test on 
pg_upgrade/t with the other ones since this issue is related with 
pg_upgrade itself, but others can have other opinions on this.

> I'm attaching the patch with the fix and also with the test for it.
> 

Thanks for reporting this and for sharing the patch.

--
Matheus Alcantara
EDB: https://www.enterprisedb.com

---

From: "Jonathan Gonzalez V." <[email-masked]>
Date: Fri, 06 Mar 2026 13:10:12 +0100

Hello!

>  that the code comment on function.c can be 
I'm attaching a v3 with an improved messages

Also updated the reviewers list on the message

Regards!
-- 
Jonathan Gonzalez V. <[email-masked]>
EnterpriseDB
From 347161a6285517755828c51a8a338c8438c3bc63 Mon Sep 17 00:00:00 2001
From: "Jonathan Gonzalez V." <[email-masked]>
Date: Mon, 23 Feb 2026 22:27:51 +0100
Subject: [PATCH v3 1/1] Strip `$libdir` during pg_upgrade starting on 19.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Reviewed-by: NiccolÃ² Fei <[email-masked]>
Reviewed-by: Matheus Alcantara <[email-masked]>
---
 src/bin/pg_upgrade/Makefile                   |   6 +-
 src/bin/pg_upgrade/function.c                 |   8 ++
 src/bin/pg_upgrade/meson.build                |  21 ++-
 .../t/008_extension_control_path.pl           | 126 ++++++++++++++++++
 src/test/modules/test_extensions/Makefile     |   3 +
 src/test/modules/test_extensions/meson.build  |  13 ++
 src/test/modules/test_extensions/test_ext.c   |  22 +++
 7 files changed, 197 insertions(+), 2 deletions(-)
 create mode 100644 src/bin/pg_upgrade/t/008_extension_control_path.pl
 create mode 100644 src/test/modules/test_extensions/test_ext.c

 

 pg_upgrade: $(OBJS) | submake-libpq submake-libpgport submake-libpgfeutils
diff --git a/src/bin/pg_upgrade/function.c b/src/bin/pg_upgrade/function.c
index a3184f95665..bc7e8006d82 100644
--- a/src/bin/pg_upgrade/function.c
+++ b/src/bin/pg_upgrade/function.c
@@ -120,6 +120,14 @@ get_loadable_libraries(void)
 		{
 			char	   *lib = PQgetvalue(res, rowno, 0);
 
+			/*
+			 * Starting with version 19, for extensions with hardcoded
+			 * '$libdir/' library names, we strip the prefix to allow the
+			 * library search path to be used.
+			 */
+			if (strncmp(lib, "$libdir/", 8) == 0)
+				lib += 8;
+
 			os_info.libraries[totaltups].name = pg_strdup(lib);
 			os_info.libraries[totaltups].dbnum = dbnum;

 
 tests += {
   'name': 'pg_upgrade',
 
 MODULE = test_extensions
+MODULE_big = test_ext

+OBJS = test_ext.o
+

---

From: Peter Eisentraut <[email-masked]>
Date: Mon, 16 Mar 2026 11:57:14 +0100

On 09.03.26 21:49, Matheus Alcantara wrote:
> On 06/03/26 09:10, Jonathan Gonzalez V. wrote:
>> Hello!
>>
> Thanks for updating the patch, it looks good to me and fix the described 
> issue.

committed