From 095da1cdc53b4a2cc6cd2c766c1acf0f5cc9f43a Mon Sep 17 00:00:00 2001
From: Fujii Masao <fujii@postgresql.org>
Date: Thu, 16 Jul 2026 11:47:05 +0900
Subject: [PATCH v1] vacuumdb: Use ANALYZE ONLY for partitioned tables

When vacuumdb --analyze-only or --analyze-in-stages enumerates
tables itself, it can select both a partitioned table and its
partitions as separate work items. Previously, it generated plain
ANALYZE for the partitioned table, which recursively analyzed its
partitions as well.

This caused duplicate work when the partitions were already selected by
vacuumdb. With --analyze-in-stages, the redundant work could be
repeated at every stage.

Fix this by generating ANALYZE ONLY for automatically selected
partitioned tables. This updates inherited statistics for the
partitioned table while leaving per-partition statistics to the
separately selected partition entries.

This commit does not change the behavior for partitioned tables
specified explicitly with --table, i.e., in that case, vacuumdb continues
to generate plain ANALYZE, recursively analyzing the specified
partitioned table and its partitions.

Suggested-by: Justin Pryzby <pryzby@telsasoft.com>
Author: Fujii Masao <masao.fujii@gmail.com>
Discussion: https://postgr.es/m/aKZ7lMYGnnIo35c0@pryzbyj2023
Backpatch-through: 19
---
 src/bin/scripts/t/100_vacuumdb.pl |   8 +-
 src/bin/scripts/vacuuming.c       | 119 ++++++++++++++++++++----------
 src/tools/pgindent/typedefs.list  |   2 +
 3 files changed, 87 insertions(+), 42 deletions(-)

diff --git a/src/bin/scripts/t/100_vacuumdb.pl b/src/bin/scripts/t/100_vacuumdb.pl
index 58e38971b3d..7c4e35a6717 100644
--- a/src/bin/scripts/t/100_vacuumdb.pl
+++ b/src/bin/scripts/t/100_vacuumdb.pl
@@ -362,12 +362,16 @@ $node->safe_psql('postgres',
 	  . "INSERT INTO parent_table VALUES (1);\n");
 $node->issues_sql_like(
 	[ 'vacuumdb', '--analyze-only', 'postgres' ],
-	qr/statement: ANALYZE public.parent_table/s,
+	qr/statement: ANALYZE ONLY public.parent_table/s,
 	'--analyze-only updates statistics for partitioned tables');
 $node->issues_sql_like(
 	[ 'vacuumdb', '--analyze-in-stages', 'postgres' ],
-	qr/statement: ANALYZE public.parent_table/s,
+	qr/statement: ANALYZE ONLY public.parent_table/s,
 	'--analyze-in-stages updates statistics for partitioned tables');
+$node->issues_sql_like(
+	[ 'vacuumdb', '--analyze-only', '-t', 'parent_table', 'postgres' ],
+	qr/statement: ANALYZE public.parent_table/s,
+	'--analyze-only with --table keeps normal ANALYZE recursion');
 $node->issues_sql_unlike(
 	[ 'vacuumdb', '--analyze-only', 'postgres' ],
 	qr/statement:\ VACUUM/sx,
diff --git a/src/bin/scripts/vacuuming.c b/src/bin/scripts/vacuuming.c
index 855a5754c98..f34ed37ef4f 100644
--- a/src/bin/scripts/vacuuming.c
+++ b/src/bin/scripts/vacuuming.c
@@ -23,12 +23,23 @@
 #include "fe_utils/string_utils.h"
 #include "vacuuming.h"
 
+typedef struct RetrievedObject
+{
+	char	   *target;
+	bool		use_only;
+} RetrievedObject;
+
+typedef struct RetrievedObjects
+{
+	int			num_objects;
+	RetrievedObject objects[FLEXIBLE_ARRAY_MEMBER];
+} RetrievedObjects;
 
 static int	vacuum_one_database(ConnParams *cparams,
 								vacuumingOptions *vacopts,
 								int stage,
 								SimpleStringList *objects,
-								SimpleStringList **found_objs,
+								RetrievedObjects **found_objs,
 								int concurrentCons,
 								const char *progname);
 static int	vacuum_all_databases(ConnParams *cparams,
@@ -36,12 +47,13 @@ static int	vacuum_all_databases(ConnParams *cparams,
 								 SimpleStringList *objects,
 								 int concurrentCons,
 								 const char *progname);
-static SimpleStringList *retrieve_objects(PGconn *conn,
+static RetrievedObjects *retrieve_objects(PGconn *conn,
 										  vacuumingOptions *vacopts,
 										  SimpleStringList *objects);
-static void free_retrieved_objects(SimpleStringList *list);
+static void free_retrieved_objects(RetrievedObjects *objs);
 static void prepare_vacuum_command(PGconn *conn, PQExpBuffer sql,
-								   vacuumingOptions *vacopts, const char *table);
+								   vacuumingOptions *vacopts, const char *table,
+								   bool use_only);
 static void run_vacuum_command(ParallelSlot *free_slot,
 							   vacuumingOptions *vacopts, const char *sql,
 							   const char *table);
@@ -89,7 +101,7 @@ vacuuming_main(ConnParams *cparams, const char *dbname,
 
 		if (vacopts->mode == MODE_ANALYZE_IN_STAGES)
 		{
-			SimpleStringList *found_objs = NULL;
+			RetrievedObjects *found_objs = NULL;
 
 			for (int stage = 0; stage < ANALYZE_NUM_STAGES; stage++)
 			{
@@ -127,9 +139,9 @@ vacuuming_main(ConnParams *cparams, const char *dbname,
  *
  * There are two ways to specify the list of objects to process:
  *
- * 1) The "found_objs" parameter is a double pointer to a fully qualified list
- *    of objects to process, as returned by a previous call to
- *    vacuum_one_database().
+ * 1) The "found_objs" parameter is a double pointer to a list of fully
+ *    qualified objects and their command generation metadata, as returned by
+ *    a previous call to vacuum_one_database().
  *
  *     a) If both "found_objs" (the double pointer) and "*found_objs" (the
  *        once-dereferenced double pointer) are not NULL, this list takes
@@ -165,17 +177,16 @@ vacuum_one_database(ConnParams *cparams,
 					vacuumingOptions *vacopts,
 					int stage,
 					SimpleStringList *objects,
-					SimpleStringList **found_objs,
+					RetrievedObjects **found_objs,
 					int concurrentCons,
 					const char *progname)
 {
 	PQExpBufferData sql;
 	PGconn	   *conn;
-	SimpleStringListCell *cell;
 	ParallelSlotArray *sa;
 	int			ntups = 0;
 	const char *initcmd;
-	SimpleStringList *retobjs = NULL;
+	RetrievedObjects *retobjs = NULL;
 	bool		free_retobjs = false;
 	int			ret = EXIT_SUCCESS;
 	const char *stage_commands[] = {
@@ -295,7 +306,7 @@ vacuum_one_database(ConnParams *cparams,
 	/*
 	 * If the caller provided the results of a previous catalog query, just
 	 * use that.  Otherwise, run the catalog query ourselves and set the
-	 * return variable if provided.  (If it is, then freeing the string list
+	 * return variable if provided.  (If it is, then freeing the results
 	 * becomes the caller's responsibility.)
 	 */
 	if (found_objs && *found_objs)
@@ -309,12 +320,7 @@ vacuum_one_database(ConnParams *cparams,
 			free_retobjs = true;
 	}
 
-	/*
-	 * Count the number of objects in the catalog query result.  If there are
-	 * none, we are done.
-	 */
-	for (cell = retobjs->head; cell; cell = cell->next)
-		ntups++;
+	ntups = retobjs->num_objects;
 
 	if (ntups == 0)
 	{
@@ -361,10 +367,10 @@ vacuum_one_database(ConnParams *cparams,
 
 	initPQExpBuffer(&sql);
 
-	cell = retobjs->head;
-	do
+	for (int i = 0; i < ntups; i++)
 	{
-		const char *tabname = cell->val;
+		RetrievedObject *object = &retobjs->objects[i];
+		const char *tabname = object->target;
 		ParallelSlot *free_slot;
 
 		if (CancelRequested)
@@ -381,7 +387,7 @@ vacuum_one_database(ConnParams *cparams,
 		}
 
 		prepare_vacuum_command(free_slot->connection, &sql,
-							   vacopts, tabname);
+							   vacopts, tabname, object->use_only);
 
 		/*
 		 * Execute the vacuum.  All errors are handled in processQueryResult
@@ -390,8 +396,7 @@ vacuum_one_database(ConnParams *cparams,
 		ParallelSlotSetHandler(free_slot, TableCommandResultHandler, NULL);
 		run_vacuum_command(free_slot, vacopts, sql.data, tabname);
 
-		cell = cell->next;
-	} while (cell != NULL);
+	}
 
 	if (!ParallelSlotsWaitCompletion(sa))
 	{
@@ -456,10 +461,10 @@ vacuum_all_databases(ConnParams *cparams,
 
 	if (vacopts->mode == MODE_ANALYZE_IN_STAGES)
 	{
-		SimpleStringList **found_objs = NULL;
+		RetrievedObjects **found_objs = NULL;
 
 		if (vacopts->missing_stats_only)
-			found_objs = palloc0(numdbs * sizeof(SimpleStringList *));
+			found_objs = palloc0(numdbs * sizeof(RetrievedObjects *));
 
 		/*
 		 * When analyzing all databases in stages, we analyze them all in the
@@ -526,7 +531,7 @@ vacuum_all_databases(ConnParams *cparams,
  * generated qualified identifiers and to filter for the tables provided via
  * --table.  If a listed table does not exist, the catalog query will fail.
  */
-static SimpleStringList *
+static RetrievedObjects *
 retrieve_objects(PGconn *conn, vacuumingOptions *vacopts,
 				 SimpleStringList *objects)
 {
@@ -534,8 +539,9 @@ retrieve_objects(PGconn *conn, vacuumingOptions *vacopts,
 	PQExpBufferData catalog_query;
 	PGresult   *res;
 	SimpleStringListCell *cell;
-	SimpleStringList *found_objs = palloc0_object(SimpleStringList);
+	RetrievedObjects *found_objs;
 	bool		objects_listed = false;
+	int			ntups;
 
 	initPQExpBuffer(&catalog_query);
 	for (cell = objects ? objects->head : NULL; cell; cell = cell->next)
@@ -588,7 +594,7 @@ retrieve_objects(PGconn *conn, vacuumingOptions *vacopts,
 	if (objects_listed)
 		appendPQExpBufferStr(&catalog_query, "\n)\n");
 
-	appendPQExpBufferStr(&catalog_query, "SELECT c.relname, ns.nspname");
+	appendPQExpBufferStr(&catalog_query, "SELECT c.relname, ns.nspname, c.relkind");
 
 	if (objects_listed)
 		appendPQExpBufferStr(&catalog_query, ", listed_objects.column_list");
@@ -791,18 +797,39 @@ retrieve_objects(PGconn *conn, vacuumingOptions *vacopts,
 	 * Build qualified identifiers for each table, including the column list
 	 * if given.
 	 */
+	ntups = PQntuples(res);
+	found_objs = palloc0(add_size(offsetof(RetrievedObjects, objects),
+								  mul_size(sizeof(RetrievedObject), ntups)));
+	found_objs->num_objects = ntups;
+
 	initPQExpBuffer(&buf);
-	for (int i = 0; i < PQntuples(res); i++)
+	for (int i = 0; i < found_objs->num_objects; i++)
 	{
+		RetrievedObject *object;
+		bool		use_only;
+
+		/*
+		 * For automatically enumerated partitioned tables, use ONLY to
+		 * collect inherited statistics without recursively updating
+		 * per-partition statistics.  Partitions selected by the current
+		 * filters are processed as separate targets.
+		 */
+		use_only = ((vacopts->mode == MODE_ANALYZE ||
+					 vacopts->mode == MODE_ANALYZE_IN_STAGES) &&
+					(vacopts->objfilter & OBJFILTER_TABLE) == 0 &&
+					PQgetvalue(res, i, 2)[0] == RELKIND_PARTITIONED_TABLE);
+
 		appendPQExpBufferStr(&buf,
 							 fmtQualifiedIdEnc(PQgetvalue(res, i, 1),
 											   PQgetvalue(res, i, 0),
 											   PQclientEncoding(conn)));
 
-		if (objects_listed && !PQgetisnull(res, i, 2))
-			appendPQExpBufferStr(&buf, PQgetvalue(res, i, 2));
+		if (objects_listed && !PQgetisnull(res, i, 3))
+			appendPQExpBufferStr(&buf, PQgetvalue(res, i, 3));
 
-		simple_string_list_append(found_objs, buf.data);
+		object = &found_objs->objects[i];
+		object->target = pg_strdup(buf.data);
+		object->use_only = use_only;
 		resetPQExpBuffer(&buf);
 	}
 	termPQExpBuffer(&buf);
@@ -818,12 +845,14 @@ retrieve_objects(PGconn *conn, vacuumingOptions *vacopts,
  * although retrieve_objects() will never return that.
  */
 static void
-free_retrieved_objects(SimpleStringList *list)
+free_retrieved_objects(RetrievedObjects *objs)
 {
-	if (list)
+	if (objs)
 	{
-		simple_string_list_destroy(list);
-		pg_free(list);
+		for (int i = 0; i < objs->num_objects; i++)
+			pg_free(objs->objects[i].target);
+
+		pg_free(objs);
 	}
 }
 
@@ -831,18 +860,25 @@ free_retrieved_objects(SimpleStringList *list)
  * Construct a vacuum/analyze command to run based on the given
  * options, in the given string buffer, which may contain previous garbage.
  *
- * The table name used must be already properly quoted.  The command generated
- * depends on the server version involved and it is semicolon-terminated.
+ * The table reference used must be already properly quoted.  It is usually a
+ * table name, but may also include a column list.  The command generated
+ * depends on the server version involved and it is semicolon-terminated.  If
+ * use_only is set, the command targets the table with ANALYZE ONLY.
  */
 static void
 prepare_vacuum_command(PGconn *conn, PQExpBuffer sql,
-					   vacuumingOptions *vacopts, const char *table)
+					   vacuumingOptions *vacopts, const char *table,
+					   bool use_only)
 {
 	int			serverVersion = PQserverVersion(conn);
 	const char *paren = " (";
 	const char *comma = ", ";
 	const char *sep = paren;
 
+	Assert(!use_only ||
+		   vacopts->mode == MODE_ANALYZE ||
+		   vacopts->mode == MODE_ANALYZE_IN_STAGES);
+
 	resetPQExpBuffer(sql);
 
 	if (vacopts->mode == MODE_ANALYZE ||
@@ -880,6 +916,9 @@ prepare_vacuum_command(PGconn *conn, PQExpBuffer sql,
 			if (vacopts->verbose)
 				appendPQExpBufferStr(sql, " VERBOSE");
 		}
+
+		if (use_only)
+			appendPQExpBufferStr(sql, " ONLY");
 	}
 	else
 	{
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 56c1f997f88..801e692d008 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2702,6 +2702,8 @@ ResultState
 ResultType
 RetainDeadTuplesData
 RetainDeadTuplesPhase
+RetrievedObject
+RetrievedObjects
 ReturnSetInfo
 ReturnStmt
 ReturningClause
-- 
2.55.0

