From 5ecbf41a188f468331301da3685696b58120b85b Mon Sep 17 00:00:00 2001
From: Melanie Plageman <melanieplageman@gmail.com>
Date: Wed, 11 Nov 2026 17:11:18 -0500
Subject: [PATCH v2 1/2] Avoid unreliable VACUUM reltuples estimates

VACUUM protects pg_class.reltuples from tiny, non-random samples by
retaining the old estimate when it scans very few pages. It did so even
when the relation size changed, preserving a stale absolute count instead
of scaling the old tuple density to the new size.

VACUUM also reported an unknown live-tuple estimate as zero to the
cumulative statistics system, overwriting its existing n_live_tup
estimate.

Commit b46e1e54d ("Allow on-access pruning to set pages all-visible")
made these pre-existing problems reachable in ordinary workloads. A read
can now mark most pages all-visible after INSERT, COPY, or CREATE TABLE AS
but before VACUUM or ANALYZE establishes a tuple-density estimate.
VACUUM may consequently skip nearly all populated pages and scan only a
tiny, non-random tail sample while reltuples is still unknown or reflects
a previously empty table.

When no prior density is available, treat a sample of at most one page or
less than two percent of the relation as insufficient, and leave
reltuples unknown rather than extrapolating. Apply the small-sample
stability rule only when the relation size is unchanged; otherwise, scale
the old density to the new size.

Pass an unknown estimate to the cumulative statistics system as -1, and
preserve its existing n_live_tup estimate rather than replacing it with
zero.
---
 src/backend/commands/vacuum.c                | 30 +++++++++++++-------
 src/backend/utils/activity/pgstat_relation.c |  4 ++-
 2 files changed, 22 insertions(+), 12 deletions(-)

diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 3a5c1efd6ac..c46de3c1a86 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -1371,6 +1371,20 @@ vac_estimate_reltuples(Relation relation,
 	if (scanned_pages >= total_pages)
 		return scanned_tuples;
 
+	/*
+	 * With no prior density, a tiny non-random sample is not sufficient to
+	 * estimate the relation's tuple count.  This can happen when on-access
+	 * pruning marks most pages all-visible before the first VACUUM.
+	 */
+	if (old_rel_tuples < 0 || old_rel_pages == 0)
+	{
+		if (scanned_pages <= 1 ||
+			scanned_pages < (double) total_pages * 0.02)
+			return -1;
+
+		return floor((scanned_tuples / scanned_pages) * total_pages + 0.5);
+	}
+
 	/*
 	 * When successive VACUUM commands scan the same few pages again and
 	 * again, without anything from the table really changing, there is a risk
@@ -1381,23 +1395,17 @@ vac_estimate_reltuples(Relation relation,
 	 * If the relation is _exactly_ the same size according to the existing
 	 * pg_class entry, and only a few of its pages (less than 2%) were
 	 * scanned, keep the existing value of reltuples.  Also keep the existing
-	 * value when only a subset of rel's pages <= a single page were scanned.
+	 * value when at most one page was scanned.  When the relation size has
+	 * changed, use the old density to account for the added or removed pages
+	 * instead of preserving a stale tuple count.
 	 *
 	 * (Note: we might be returning -1 here.)
 	 */
 	if (old_rel_pages == total_pages &&
-		scanned_pages < (double) total_pages * 0.02)
-		return old_rel_tuples;
-	if (scanned_pages <= 1)
+		(scanned_pages <= 1 ||
+		 scanned_pages < (double) total_pages * 0.02))
 		return old_rel_tuples;
 
-	/*
-	 * If old density is unknown, we can't do much except scale up
-	 * scanned_tuples to match total_pages.
-	 */
-	if (old_rel_tuples < 0 || old_rel_pages == 0)
-		return floor((scanned_tuples / scanned_pages) * total_pages + 0.5);
-
 	/*
 	 * Okay, we've covered the corner cases.  The normal calculation is to
 	 * convert the old measurement to a density (tuples per page), then
diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c
index 5c70543ba88..4be20826cd1 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -258,6 +258,7 @@ pgstat_drop_relation(Relation rel)
 
 /*
  * Report that the table was just vacuumed and flush IO statistics.
+ * livetuples is -1 if VACUUM could not produce a new estimate.
  */
 void
 pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples,
@@ -284,7 +285,8 @@ pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples,
 	shtabentry = (PgStatShared_Relation *) entry_ref->shared_stats;
 	tabentry = &shtabentry->stats;
 
-	tabentry->live_tuples = livetuples;
+	if (livetuples >= 0)
+		tabentry->live_tuples = livetuples;
 	tabentry->dead_tuples = deadtuples;
 
 	/*
-- 
2.43.0

