From b238d9f8660c360e920f98eb6be586d3a2dd15b3 Mon Sep 17 00:00:00 2001
From: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Date: Wed, 8 Oct 2025 16:47:43 +0000
Subject: [PATCH v1] Preserve index stats during ALTER TABLE ... TYPE ...

During ALTER TABLE ... TYPE ... on an indexed column, a new index is created and
the old one is dropped. Currently this causes the statistics such as idx_scan,
last_idx_scan, and related counters to be lost.

We can not use pgstat_copy_relation_stats() because the old index is dropped
before the new one is created, so this commit adds a new PgStat_StatTabEntry
pointer in AlteredTableInfo.

The stats are saved in ATPostAlterTypeParse() (before the old index is dropped)
and restored in ATExecAddIndex() once the new index is created.

Note that pending statistics (if any) are not preserved, only the
accumulated stats from previous transactions. This is acceptable since the
accumulated stats represent the historical usage patterns we want to maintain.
---
 src/backend/commands/tablecmds.c    | 60 +++++++++++++++++++++++++
 src/test/regress/expected/stats.out | 70 ++++++++++++++++++++++++++++-
 src/test/regress/sql/stats.sql      | 31 ++++++++++++-
 3 files changed, 157 insertions(+), 4 deletions(-)
  30.6% src/backend/commands/
  39.8% src/test/regress/expected/
  29.5% src/test/regress/sql/

diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fc89352b661..5353bbc3ec1 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -102,6 +102,7 @@
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/partcache.h"
+#include "utils/pgstat_internal.h"
 #include "utils/relcache.h"
 #include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
@@ -208,6 +209,7 @@ typedef struct AlteredTableInfo
 	char	   *clusterOnIndex; /* index to use for CLUSTER */
 	List	   *changedStatisticsOids;	/* OIDs of statistics to rebuild */
 	List	   *changedStatisticsDefs;	/* string definitions of same */
+	PgStat_StatTabEntry *savedIndexStats;	/* stats from old index, if any */
 } AlteredTableInfo;
 
 /* Struct describing one new constraint to check in Phase 3 scan */
@@ -9637,6 +9639,36 @@ ATExecAddIndex(AlteredTableInfo *tab, Relation rel,
 		index_close(irel, NoLock);
 	}
 
+	/* restore the statistics from the old index */
+	if (tab->savedIndexStats != NULL)
+	{
+		PgStatShared_Relation *shstats;
+		PgStat_EntryRef *entry_ref;
+		Relation	irel = index_open(address.objectId, NoLock);
+
+		/* get or create the stats entry for the new index */
+		entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_RELATION,
+												irel->rd_rel->relisshared ?
+												InvalidOid : MyDatabaseId,
+												irel->rd_id,
+												false);
+
+		if (entry_ref != NULL)
+		{
+			shstats = (PgStatShared_Relation *) entry_ref->shared_stats;
+
+			/* copy the saved statistics */
+			shstats->stats = *(tab->savedIndexStats);
+
+			pgstat_unlock_entry(entry_ref);
+		}
+
+		pfree(tab->savedIndexStats);
+		tab->savedIndexStats = NULL;
+
+		index_close(irel, NoLock);
+	}
+
 	return address;
 }
 
@@ -15664,9 +15696,22 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, char *cmd,
 		{
 			IndexStmt  *stmt = (IndexStmt *) stm;
 			AlterTableCmd *newcmd;
+			PgStat_StatTabEntry *src_stats = pgstat_fetch_stat_tabentry(oldId);
+
+			/* keep the index's statistics */
+			if (src_stats)
+			{
+				tab->savedIndexStats = (PgStat_StatTabEntry *) palloc(sizeof(PgStat_StatTabEntry));
+				memcpy(tab->savedIndexStats, src_stats, sizeof(PgStat_StatTabEntry));
+			}
+			else
+			{
+				tab->savedIndexStats = NULL;
+			}
 
 			if (!rewrite)
 				TryReuseIndex(oldId, stmt);
+
 			stmt->reset_default_tblspc = true;
 			/* keep the index's comment */
 			stmt->idxcomment = GetComment(oldId, RelationRelationId, 0);
@@ -15690,12 +15735,27 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, char *cmd,
 				{
 					IndexStmt  *indstmt;
 					Oid			indoid;
+					PgStat_StatTabEntry *src_stats;
 
 					indstmt = castNode(IndexStmt, cmd->def);
 					indoid = get_constraint_index(oldId);
 
+					/* keep the index's statistics */
+					src_stats = pgstat_fetch_stat_tabentry(indoid);
+
+					if (src_stats)
+					{
+						tab->savedIndexStats = (PgStat_StatTabEntry *) palloc(sizeof(PgStat_StatTabEntry));
+						memcpy(tab->savedIndexStats, src_stats, sizeof(PgStat_StatTabEntry));
+					}
+					else
+					{
+						tab->savedIndexStats = NULL;
+					}
+
 					if (!rewrite)
 						TryReuseIndex(indoid, indstmt);
+
 					/* keep any comment on the index */
 					indstmt->idxcomment = GetComment(indoid,
 													 RelationRelationId, 0);
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index 67e1860e984..7864a8f44b8 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -651,8 +651,9 @@ DROP TABLE prevstats;
 -- granularity.
 -----
 BEGIN;
-CREATE TEMPORARY TABLE test_last_scan(idx_col int primary key, noidx_col int);
-INSERT INTO test_last_scan(idx_col, noidx_col) VALUES(1, 1);
+CREATE TEMPORARY TABLE test_last_scan(idx_col int primary key, idx_col2 int, noidx_col int);
+CREATE index test_last_scan_idx2 on test_last_scan(idx_col2);
+INSERT INTO test_last_scan(idx_col, idx_col2, noidx_col) VALUES(1, 1, 1);
 SELECT pg_stat_force_next_flush();
  pg_stat_force_next_flush 
 --------------------------
@@ -867,6 +868,71 @@ SELECT idx_scan, :'test_last_idx' < last_idx_scan AS idx_ok,
         3 | t      | f
 (1 row)
 
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan ALTER COLUMN idx_col TYPE int;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        3
+(1 row)
+
+-- same test but with a rewrite
+ALTER TABLE test_last_scan ALTER COLUMN idx_col TYPE bigint;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        3
+(1 row)
+
+-- do the same on an indexed column not part of a constraint
+-- cause one index scan
+BEGIN;
+SET LOCAL enable_seqscan TO off;
+SET LOCAL enable_indexscan TO on;
+SET LOCAL enable_bitmapscan TO off;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_last_scan WHERE idx_col2 = 1;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Aggregate
+   ->  Index Scan using test_last_scan_idx2 on test_last_scan
+         Index Cond: (idx_col2 = 1)
+(3 rows)
+
+SELECT count(*) FROM test_last_scan WHERE idx_col2 = 1;
+ count 
+-------
+     1
+(1 row)
+
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+COMMIT;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        4
+(1 row)
+
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan ALTER COLUMN idx_col2 TYPE int;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        4
+(1 row)
+
+-- same test but with a rewrite
+ALTER TABLE test_last_scan ALTER COLUMN idx_col2 TYPE bigint;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+ idx_scan 
+----------
+        4
+(1 row)
+
 -- check that the stats in pg_stat_all_indexes are reset
 SELECT pg_stat_reset_single_table_counters('test_last_scan_pkey'::regclass);
  pg_stat_reset_single_table_counters 
diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql
index 8768e0f27fd..eaf359aeba1 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -306,8 +306,9 @@ DROP TABLE prevstats;
 -----
 
 BEGIN;
-CREATE TEMPORARY TABLE test_last_scan(idx_col int primary key, noidx_col int);
-INSERT INTO test_last_scan(idx_col, noidx_col) VALUES(1, 1);
+CREATE TEMPORARY TABLE test_last_scan(idx_col int primary key, idx_col2 int, noidx_col int);
+CREATE index test_last_scan_idx2 on test_last_scan(idx_col2);
+INSERT INTO test_last_scan(idx_col, idx_col2, noidx_col) VALUES(1, 1, 1);
 SELECT pg_stat_force_next_flush();
 SELECT last_seq_scan, last_idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
 COMMIT;
@@ -390,6 +391,32 @@ SELECT idx_scan, :'test_last_idx' < last_idx_scan AS idx_ok,
   stats_reset IS NOT NULL AS has_stats_reset
   FROM pg_stat_all_indexes WHERE indexrelid = 'test_last_scan_pkey'::regclass;
 
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan ALTER COLUMN idx_col TYPE int;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+-- same test but with a rewrite
+ALTER TABLE test_last_scan ALTER COLUMN idx_col TYPE bigint;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+
+-- do the same on an indexed column not part of a constraint
+-- cause one index scan
+BEGIN;
+SET LOCAL enable_seqscan TO off;
+SET LOCAL enable_indexscan TO on;
+SET LOCAL enable_bitmapscan TO off;
+EXPLAIN (COSTS off) SELECT count(*) FROM test_last_scan WHERE idx_col2 = 1;
+SELECT count(*) FROM test_last_scan WHERE idx_col2 = 1;
+SELECT pg_stat_force_next_flush();
+COMMIT;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+
+-- check that an index rebuild preserves the stats
+ALTER TABLE test_last_scan ALTER COLUMN idx_col2 TYPE int;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+-- same test but with a rewrite
+ALTER TABLE test_last_scan ALTER COLUMN idx_col2 TYPE bigint;
+SELECT idx_scan FROM pg_stat_all_tables WHERE relid = 'test_last_scan'::regclass;
+
 -- check that the stats in pg_stat_all_indexes are reset
 SELECT pg_stat_reset_single_table_counters('test_last_scan_pkey'::regclass);
 
-- 
2.34.1

