From 21df920e307eb5d534c0cb3459824ee6e82d6a31 Mon Sep 17 00:00:00 2001
From: Zhong ShiHao <zhong950419@gmail.com>
Date: Mon, 31 Aug 2026 20:42:44 -0400
Subject: [PATCH] Don't let a larger autovacuum freeze weight lower the freeze
 score

Raising a freeze weight lowers the age at which the score starts scaling,
but the score is scaled by raising it to a power, which shrinks it when it
is below one.  A weight above 1.0 could thus collapse the score of a table
close to wraparound, demoting it below younger tables.  Apply the exponent
only when it increases the score, for both components.

Add a test that fakes an old relfrozenxid/relminmxid (safe: the score reads
them only arithmetically, never via clog) and checks neither score drops as
its weight grows.  New in v19 (d7965d65fc5), not back-patched.
---
 src/backend/postmaster/autovacuum.c           | 10 ++-
 src/test/modules/test_autovacuum/meson.build  |  1 +
 .../test_autovacuum/t/003_freeze_score.pl     | 85 +++++++++++++++++++
 3 files changed, 94 insertions(+), 2 deletions(-)
 create mode 100644 src/test/modules/test_autovacuum/t/003_freeze_score.pl

diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index 393e81d53e9..480f16b5c74 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -3210,9 +3210,15 @@ relation_needs_vacanalyze(Oid relid,
 	if (autovacuum_multixact_freeze_score_weight > 1.0)
 		effective_mxid_failsafe_age /= autovacuum_multixact_freeze_score_weight;
 
-	if (xid_age >= effective_xid_failsafe_age)
+	/*
+	 * Only apply the exponent when it increases the score.  Dividing the
+	 * effective ages by the weights above can let a score below one reach this
+	 * point, and raising that to a power shrinks it, so a larger weight could
+	 * otherwise demote a table that is closer to wraparound.
+	 */
+	if (xid_age >= effective_xid_failsafe_age && scores->xid > 1.0)
 		scores->xid = pow(scores->xid, Max(1.0, (double) xid_age / 100000000));
-	if (mxid_age >= effective_mxid_failsafe_age)
+	if (mxid_age >= effective_mxid_failsafe_age && scores->mxid > 1.0)
 		scores->mxid = pow(scores->mxid, Max(1.0, (double) mxid_age / 100000000));
 
 	scores->xid *= autovacuum_freeze_score_weight;
diff --git a/src/test/modules/test_autovacuum/meson.build b/src/test/modules/test_autovacuum/meson.build
index 970b9aaae4b..56601789960 100644
--- a/src/test/modules/test_autovacuum/meson.build
+++ b/src/test/modules/test_autovacuum/meson.build
@@ -11,6 +11,7 @@ tests += {
     'tests': [
       't/001_parallel_autovacuum.pl',
       't/002_toast_relopts.pl',
+      't/003_freeze_score.pl',
     ],
   },
 }
diff --git a/src/test/modules/test_autovacuum/t/003_freeze_score.pl b/src/test/modules/test_autovacuum/t/003_freeze_score.pl
new file mode 100644
index 00000000000..7769ea1fb07
--- /dev/null
+++ b/src/test/modules/test_autovacuum/t/003_freeze_score.pl
@@ -0,0 +1,85 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Raising an autovacuum freeze weight must never lower the corresponding
+# freeze score, for both the transaction ID and multixact components.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+
+# Keep the *_freeze_max_age parameters well above the faked ages so the table
+# is not force-vacuumed for wraparound, and drop the failsafe ages to their
+# minimum so *_freeze_max_age alone governs the scaling threshold.
+$node->append_conf(
+	'postgresql.conf', qq[
+autovacuum = off
+autovacuum_freeze_max_age = 1000000000
+autovacuum_multixact_freeze_max_age = 1000000000
+vacuum_failsafe_age = 0
+vacuum_multixact_failsafe_age = 0
+]);
+$node->start;
+
+$node->safe_psql('postgres',
+	'CREATE TABLE freezetest (i int) WITH (autovacuum_enabled = off)');
+
+# Fake old relfrozenxid/relminmxid rather than consuming hundreds of millions
+# of transactions.  The score view only reads these fields arithmetically
+# (recentXid - relfrozenxid, recentMulti - relminmxid, both modulo 2^32) and
+# never consults clog for them, and with autovacuum off and the faked ages
+# below *_freeze_max_age nothing else acts on them, so this is safe.  There is
+# no SQL primitive for the next multixact, but on a freshly initialized node it
+# is near the next xid, so subtracting from pg_snapshot_xmax() yields a usable
+# multixact age too; the assertions below confirm both ages landed in range.
+my $age = 300_000_000;
+$node->safe_psql(
+	'postgres', qq[
+UPDATE pg_class
+   SET relfrozenxid = ((pg_snapshot_xmax(pg_current_snapshot())::text::bigint
+                        - $age + 4294967296) % 4294967296)::text::xid,
+       relminmxid = ((pg_snapshot_xmax(pg_current_snapshot())::text::bigint
+                      - $age + 4294967296) % 4294967296)::text::xid
+ WHERE relname = 'freezetest']);
+
+my ($xid_age, $mxid_age) = split /\|/, $node->safe_psql('postgres',
+	q[SELECT age(relfrozenxid) || '|' || mxid_age(relminmxid)
+	    FROM pg_class WHERE relname = 'freezetest']);
+cmp_ok($xid_age, '>', 100_000_000, 'xid age reaches the scaled regime');
+cmp_ok($mxid_age, '>', 100_000_000, 'mxid age reaches the scaled regime');
+
+# Weights near 3.5 are where the unguarded code collapsed the score.
+my (%prev);
+foreach my $weight (1.0, 2.0, 3.0, 3.5, 5.0, 10.0)
+{
+	foreach my $kind (qw(freeze multixact_freeze))
+	{
+		$node->safe_psql('postgres',
+			"ALTER SYSTEM SET autovacuum_${kind}_score_weight = $weight");
+	}
+	$node->reload;
+	$node->poll_query_until('postgres',
+		"SELECT current_setting('autovacuum_freeze_score_weight')::float8 = $weight"
+	) or die "timed out waiting for weight $weight";
+
+	my ($xid, $mxid) = split /\|/, $node->safe_psql('postgres',
+		q[SELECT xid_score || '|' || mxid_score
+		    FROM pg_stat_autovacuum_scores WHERE relname = 'freezetest']);
+
+	foreach my $c (['xid', $xid], ['mxid', $mxid])
+	{
+		my ($name, $score) = @$c;
+		cmp_ok($score, '>', 0, "$name score positive at weight $weight");
+		cmp_ok($score, '>=', $prev{$name},
+			"$name score does not decrease at weight $weight")
+		  if defined $prev{$name};
+		$prev{$name} = $score;
+	}
+}
+
+$node->stop;
+done_testing();
-- 
2.37.1 (Apple Git-137.1)

