Hi,
While testing pg_upgrade's multixact conversion around commit 2323f03,
I ran into an adjacent, pre-existing issue.
When the old cluster's newest multixact is 0xFFFFFFFF, its checkpoint
has NextMultiXactId = 0. copy_xact_xlog_xid() maps that to
FirstMultiXactId for pg_resetwal, but set_frozenxids() writes the raw 0
into pg_database.datminmxid and pg_class.relminmxid (the code dates to
a61daa14d56, 2014).
The effect:
- on assert-enabled builds, VACUUM fails Assert(MultiXactIdIsValid(...));
- otherwise template0 keeps datminmxid = 0 (mxid_age 2147483647) and
multixact truncation is skipped ("cannot truncate up to MultiXact 0
... skipping truncation") until autovacuum vacuums template0 -- which
it does within ~60 s by default, but never with autovacuum = off.
19's new oldestMultiXid consistency check does not catch it: it uses
modular arithmetic, so 0 "follows" 0xFFFFFFFF and passes. It
reproduces with pg_upgrade 18 and master.
The attached patch applies the same adjustment in set_frozenxids(), not
in controldata.c: the raw nextMulti = 0 is still needed so the multixact
reader sees that 0xFFFFFFFF is the newest multixid -- the case 2323f03
fixed. It also adds a case to t/007_multixact_conversion.pl.
Measured on cassert builds from 14..18 across 8 offset/multixid
wraparound boundaries (40 upgrades, plus --link from 18): with the fix,
no invalid datminmxid/relminmxid and no other datminmxid change (only
template0's 0 -> 1 in the 8 affected upgrades); without it, VACUUM traps
and the two new test checks fail. check-world passes.
So the severity is low with the default autovacuum, persistent with
autovacuum off, and a hard failure on assert-enabled builds.
Regards,
Manu
From ed4c75ee737af29b28dcbb635c17a54531bd5a19 Mon Sep 17 00:00:00 2001
From: Manu <[email protected]>
Date: Sat, 17 Oct 2026 01:24:07 -0300
Subject: [PATCH v2] pg_upgrade: Don't store an invalid datminmxid/relminmxid
If the old cluster's newest multixid was the last one before multixid
wraparound (0xFFFFFFFF), its checkpoint records NextMultiXactId = 0:
servers before v19 skip 0 only when the next multixid is assigned.
copy_xact_xlog_xid() already turns that into FirstMultiXactId before
passing it to pg_resetwal, but set_frozenxids() stored the raw 0 in
pg_database.datminmxid and in pg_class.relminmxid of the initdb-created
tables. In assert-enabled builds the next VACUUM then fails an
assertion; otherwise template0 keeps datminmxid = 0, mxid_age() reports
2147483647 for it, and VACUUM logs "cannot truncate up to MultiXact 0
because it has invalid offset, skipping truncation".
Apply the same adjustment in set_frozenxids().
---
src/bin/pg_upgrade/pg_upgrade.c | 16 ++++-
.../pg_upgrade/t/007_multixact_conversion.pl | 59 +++++++++++++++++++
2 files changed, 73 insertions(+), 2 deletions(-)
diff --git a/src/bin/pg_upgrade/pg_upgrade.c b/src/bin/pg_upgrade/pg_upgrade.c
index 6d01084..72530b7 100644
--- a/src/bin/pg_upgrade/pg_upgrade.c
+++ b/src/bin/pg_upgrade/pg_upgrade.c
@@ -913,9 +913,21 @@ set_frozenxids(void)
int ntups;
int i_datname;
int i_datallowconn;
+ MultiXactId minmxid;
prep_status("Setting frozenxid and minmxid counters in new cluster");
+ /*
+ * The old cluster's next multixid is 0 if its newest multixid was the
+ * last one before wraparound, because servers before v19 skip 0 only when
+ * assigning the next one. 0 is not a valid multixid, so use the value
+ * that it will have when the next multixid is assigned, the same that
+ * copy_xact_xlog_xid() passes to pg_resetwal.
+ */
+ minmxid = old_cluster.controldata.chkpnt_nxtmulti;
+ if (minmxid < FirstMultiXactId)
+ minmxid = FirstMultiXactId;
+
conn_template1 = connectToServer(&new_cluster, "template1");
/* set pg_database.datfrozenxid */
@@ -928,7 +940,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 +990,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 5c87c25..4cc7e1b 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