From 4cdefe1c99a6c57b98753efd5ad84abd25309bad Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sat, 17 Oct 2026 06:00:00 -0300
Subject: [PATCH v3] pg_upgrade: Don't store an invalid datminmxid/relminmxid

If the old cluster's newest multixid is the last one before multixid
wraparound (0xFFFFFFFF), the next multixid in its control file is 0:
before v19, the server skips 0 only when it assigns the next multixid.
set_frozenxids() copied that 0 into pg_database.datminmxid and into
pg_class.relminmxid of the relations that pg_upgrade does not restore
from the old cluster, i.e. the catalogs created by initdb, in every
database.  0 is InvalidMultiXactId.  On assert-enabled builds, the next
VACUUM fails an assertion.  Otherwise template0 keeps datminmxid = 0,
for which mxid_age() reports 2147483647, until autovacuum processes it;
with autovacuum = off, it stays that way.

Use FirstMultiXactId instead.  In v19, copy_xact_xlog_xid() already
does the same for the next multixid it passes to pg_resetwal when
converting multixacts.

This is not new in v19.  Before commit 8747b969fd8, pg_upgrade to
v15-v18 failed on such a cluster, because pg_resetwal refused "-m 0";
since then, the upgrade succeeds and stores the invalid values.  v14
did not get that commit, so pg_upgrade to v14 still fails on such a
cluster and is not affected.  Since commit 87a350e1f28, v19 servers
never store 0 as the next multixid, so only pre-v19 old clusters are
affected.

Backpatch-through: 15
---
 src/bin/pg_upgrade/pg_upgrade.c               | 15 ++++-
 .../pg_upgrade/t/007_multixact_conversion.pl  | 59 +++++++++++++++++++
 2 files changed, 72 insertions(+), 2 deletions(-)

diff --git a/src/bin/pg_upgrade/pg_upgrade.c b/src/bin/pg_upgrade/pg_upgrade.c
index 6d01084e4b0..1eaa2d03ba1 100644
--- a/src/bin/pg_upgrade/pg_upgrade.c
+++ b/src/bin/pg_upgrade/pg_upgrade.c
@@ -913,9 +913,20 @@ set_frozenxids(void)
 	int			ntups;
 	int			i_datname;
 	int			i_datallowconn;
+	MultiXactId minmxid;
 
 	prep_status("Setting frozenxid and minmxid counters in new cluster");
 
+	/*
+	 * If the old cluster's newest multixid was the last one before
+	 * wraparound, its next multixid is 0: servers before v19 skip 0 only
+	 * when they assign the next multixid.  0 is not a valid datminmxid or
+	 * relminmxid, so use FirstMultiXactId, as copy_xact_xlog_xid() does.
+	 */
+	minmxid = old_cluster.controldata.chkpnt_nxtmulti;
+	if (minmxid < FirstMultiXactId)
+		minmxid = FirstMultiXactId;
+
 	conn_template1 = connectToServer(&new_cluster, "template1");
 
 	/* set pg_database.datfrozenxid */
@@ -928,7 +939,7 @@ set_frozenxids(void)
 	PQclear(executeQueryOrDie(conn_template1,
 							  "UPDATE pg_catalog.pg_database "
 							  "SET	datminmxid = '%u'",
-							  old_cluster.controldata.chkpnt_nxtmulti));
+							  minmxid));
 
 	/* get database names */
 	dbres = executeQueryOrDie(conn_template1,
@@ -978,7 +989,7 @@ set_frozenxids(void)
 								  CppAsString2(RELKIND_RELATION) ", "
 								  CppAsString2(RELKIND_MATVIEW) ", "
 								  CppAsString2(RELKIND_TOASTVALUE) ")",
-								  old_cluster.controldata.chkpnt_nxtmulti));
+								  minmxid));
 		PQfinish(conn);
 
 		/* Reset datallowconn flag */
diff --git a/src/bin/pg_upgrade/t/007_multixact_conversion.pl b/src/bin/pg_upgrade/t/007_multixact_conversion.pl
index 5c87c2527ee..4cc7e1bc9ad 100644
--- a/src/bin/pg_upgrade/t/007_multixact_conversion.pl
+++ b/src/bin/pg_upgrade/t/007_multixact_conversion.pl
@@ -484,4 +484,63 @@ SKIP:
 	upgrade_and_compare($tag, $old, $new);
 }
 
+# Multixid wraparound at the last multixid: The newest multixid is
+# 0xFFFFFFFF, so the old cluster's next multixid is 0.  That must not
+# end up as datminmxid or relminmxid in the new cluster.
+SKIP:
+{
+	skip
+	  "skipping mxid wraparound tests because upgrading from the old version does not require conversion"
+	  if ($old_version >= '19devel');
+
+	my $tag = 'last_mxid';
+	my $old =
+	  PostgreSQL::Test::Cluster->new("${tag}_oldnode",
+		install_path => $ENV{oldinstall});
+	my $new = PostgreSQL::Test::Cluster->new("${tag}_newnode");
+
+	$old->init(extra => ['-k']);
+
+	reset_mxid_mxoffset_pre_v19($old, 0xFFFFFFFF, 0x10000);
+
+	# Create one multixid with two members, a locker and an updater
+	$old->start;
+	$old->safe_psql(
+		'postgres', qq[
+		CREATE TABLE mxofftest (id INT PRIMARY KEY, n_updated INT)
+		  WITH (AUTOVACUUM_ENABLED=FALSE);
+		INSERT INTO mxofftest SELECT G, 0 FROM GENERATE_SERIES(1, 2) G;
+	]);
+	my $locker =
+	  $new->background_psql('', connstr => $old->connstr('postgres'));
+	$locker->query_safe("BEGIN");
+	$locker->query_safe("SELECT * FROM mxofftest WHERE id = 1 FOR KEY SHARE");
+	$old->safe_psql('postgres',
+		"UPDATE mxofftest SET n_updated = n_updated + 1 WHERE id = 1");
+	$locker->query_safe("COMMIT");
+	$locker->quit;
+	$old->stop;
+
+	my (undef, $next_mxid, undef) = read_multixid_fields($old);
+	is($next_mxid, 0, "next multixid wrapped around to 0 in old cluster");
+
+	$new->init;
+	upgrade_and_compare($tag, $old, $new);
+
+	$new->start;
+	is( $new->safe_psql(
+			'postgres',
+			"SELECT count(*) FROM pg_database WHERE datminmxid = '0'"),
+		'0',
+		'no database has an invalid datminmxid after upgrade');
+	is( $new->safe_psql(
+			'postgres',
+			"SELECT count(*) FROM pg_class WHERE relminmxid = '0' AND relkind IN ('r', 'm', 't')"
+		),
+		'0',
+		'no table has an invalid relminmxid after upgrade');
+	$new->safe_psql('postgres', 'VACUUM');
+	$new->stop;
+}
+
 done_testing();
-- 
2.56.0

