From 262f1f61a25d274d742ea4d5c02a93bdebfcf5f0 Mon Sep 17 00:00:00 2001
From: roman khapov <r.khapov@ya.ru>
Date: Tue, 30 Jun 2026 15:03:55 +0500
Subject: [PATCH v2] Add DROP INVALID INDEXES ON TABLE command

Automates removal of invalid indexes that can appear after failed
CREATE INDEX CONCURRENTLY operations or etc. Previously required manual
queries against pg_index, which was error-prone.

Uses ShareUpdateExclusiveLock to minimize impact on concurrent
operations. Checks indisvalid and indisready flags to avoid
dropping indexes still being created.

Example of usage:
postgres=# drop invalid indexes on table sas;
NOTICE:  dropping index "idx2"
NOTICE:  dropping index "idx5"
DROP INVALID INDEXES
postgres=#

Currently without CONCURRENCY mode support.

Co-authored-by: Kirill Reshke <reshkekirill@gmail.com>
Signed-off-by: roman khapov <r.khapov@ya.ru>
---
 src/backend/catalog/index.c                   |  6 ++
 src/backend/commands/tablecmds.c              | 81 +++++++++++++++++++
 src/backend/parser/gram.y                     | 26 +++++-
 src/backend/tcop/utility.c                    | 38 +++++++++
 src/bin/psql/tab-complete.in.c                | 12 +++
 src/include/commands/tablecmds.h              |  2 +
 src/include/nodes/parsenodes.h                | 13 +++
 src/include/parser/kwlist.h                   |  1 +
 src/include/tcop/cmdtaglist.h                 |  1 +
 .../test_misc/t/014_drop_invalid_indexes.pl   | 79 ++++++++++++++++++
 src/tools/pgindent/typedefs.list              |  1 +
 11 files changed, 258 insertions(+), 2 deletions(-)
 create mode 100644 src/test/modules/test_misc/t/014_drop_invalid_indexes.pl

diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 9407c357f27..69976b21010 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -73,6 +73,7 @@
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -3100,6 +3101,11 @@ index_build(Relation heapRelation,
 											 indexInfo);
 	Assert(stats);
 
+
+#ifdef USE_INJECTION_POINTS
+	INJECTION_POINT("index-build-after-am-callback", NULL);
+#endif
+
 	/*
 	 * If this is an unlogged index, we may need to write out an init fork for
 	 * it -- but we must first check whether one already exists.  If, for
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 472db112fa7..8f283633786 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -1587,6 +1587,87 @@ DropErrorMsgWrongType(const char *relname, char wrongkind, char rightkind)
 			 (wentry->kind != '\0') ? errhint("%s", _(wentry->drophint_msg)) : 0));
 }
 
+void
+RemoveInvalidIndexes(DropInvalidIndexesStmt *drop)
+{
+	ObjectAddresses *indexesToDrop;
+	List	   *indexesList;
+	ListCell   *cell;
+	Relation	relation;
+	Oid			relOid;
+	ObjectAddress obj;
+	RangeVar   *rel;
+	LOCKMODE	lockmode = ShareUpdateExclusiveLock;
+	int			flags = 0;
+	struct DropRelationCallbackState state;
+
+	if (drop->concurrent)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("DROP INVALID INDEXES CONCURRENTLY is not supported")));
+
+	if (list_length(drop->tablenames) != 1)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("DROP INVALID INDEXES is not supported for multiple table now")));
+
+	indexesToDrop = new_object_addresses();
+
+	cell = list_head(drop->tablenames);
+
+	rel = makeRangeVarFromNameList((List *) lfirst(cell));
+	state.expected_relkind = RELKIND_RELATION;
+	/* Use ShareUpdateExclusiveLock if implementing concurrent */
+	state.heap_lockmode = AccessExclusiveLock;
+	state.heapOid = InvalidOid;
+	state.partParentOid = InvalidOid;
+
+	relOid = RangeVarGetRelidExtended(rel, lockmode, RVR_MISSING_OK,
+									  RangeVarCallbackForDropRelation,
+									  &state);
+
+	if (!OidIsValid(relOid))
+	{
+		DropErrorMsgNonExistent(rel, RELKIND_RELATION,
+								false /* missing is not ok now */ );
+		return;
+	}
+
+	relation = table_open(relOid, lockmode);
+	indexesList = RelationGetIndexList(relation);
+
+	foreach(cell, indexesList)
+	{
+		Oid			indexoid = lfirst_oid(cell);
+		Relation	indrel;
+
+		indrel = index_open(indexoid, AccessExclusiveLock);
+
+		if (!indrel->rd_index->indisvalid)
+		{
+			ereport(NOTICE,
+					(errmsg("dropping index \"%s\"",
+							RelationGetRelationName(indrel))));
+
+			obj.classId = RelationRelationId;
+			obj.objectId = indexoid;
+			obj.objectSubId = 0;
+
+			add_exact_object_address(&obj, indexesToDrop);
+		}
+
+		index_close(indrel, AccessExclusiveLock);
+	}
+
+	table_close(relation, lockmode);
+
+	list_free(indexesList);
+
+	performMultipleDeletions(indexesToDrop, drop->behavior, flags);
+
+	free_object_addresses(indexesToDrop);
+}
+
 /*
  * RemoveRelations
  *		Implements DROP TABLE, DROP INDEX, DROP SEQUENCE, DROP VIEW,
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..474c3ae070b 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -298,7 +298,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 		CreatePropGraphStmt AlterPropGraphStmt
 		CreateUserStmt CreateUserMappingStmt CreateRoleStmt CreatePolicyStmt
 		CreatedbStmt DeclareCursorStmt DefineStmt DeleteStmt DiscardStmt DoStmt
-		DropOpClassStmt DropOpFamilyStmt DropStmt
+		DropInvalidIndexesStmt DropOpClassStmt DropOpFamilyStmt DropStmt
 		DropCastStmt DropRoleStmt
 		DropdbStmt DropTableSpaceStmt
 		DropTransformStmt
@@ -778,7 +778,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	IDENTITY_P IF_P IGNORE_P ILIKE IMMEDIATE IMMUTABLE IMPLICIT_P IMPORT_P IN_P INCLUDE
 	INCLUDING INCREMENT INDENT INDEX INDEXES INHERIT INHERITS INITIALLY INLINE_P
 	INNER_P INOUT INPUT_P INSENSITIVE INSERT INSTEAD INT_P INTEGER
-	INTERSECT INTERVAL INTO INVOKER IS ISNULL ISOLATION
+	INTERSECT INTERVAL INTO INVALID_P INVOKER IS ISNULL ISOLATION
 
 	JOIN JSON JSON_ARRAY JSON_ARRAYAGG JSON_EXISTS JSON_OBJECT JSON_OBJECTAGG
 	JSON_QUERY JSON_SCALAR JSON_SERIALIZE JSON_TABLE JSON_VALUE
@@ -1118,6 +1118,7 @@ stmt:
 			| DiscardStmt
 			| DoStmt
 			| DropCastStmt
+			| DropInvalidIndexesStmt
 			| DropOpClassStmt
 			| DropOpFamilyStmt
 			| DropOwnedStmt
@@ -7100,6 +7101,26 @@ ReassignOwnedStmt:
 				}
 		;
 
+/*****************************************************************************
+ *
+ *		QUERY:
+ *
+ *		DROP INVALID INDEXES [ CONCURRENTLY ] ON TABLE tablename [, tablename ...] [ RESTRICT | CASCADE ]
+ *
+ *****************************************************************************/
+
+DropInvalidIndexesStmt:
+			DROP INVALID_P INDEXES opt_concurrently ON TABLE any_name_list opt_drop_behavior
+				{
+					DropInvalidIndexesStmt *n = makeNode(DropInvalidIndexesStmt);
+
+					n->tablenames = $7;
+					n->behavior = $8;
+					n->concurrent = $4;
+					$$ = (Node *) n;
+				}
+		;
+
 /*****************************************************************************
  *
  *		QUERY:
@@ -18958,6 +18979,7 @@ unreserved_keyword:
 			| INSENSITIVE
 			| INSERT
 			| INSTEAD
+			| INVALID_P
 			| INVOKER
 			| ISOLATION
 			| KEEP
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 73a56f1df1d..33cb6978b0e 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -82,6 +82,7 @@ static void ProcessUtilitySlow(ParseState *pstate,
 							   DestReceiver *dest,
 							   QueryCompletion *qc);
 static void ExecDropStmt(DropStmt *stmt, bool isTopLevel);
+static void ExecDropInvalidIndexes(DropInvalidIndexesStmt *stmt, bool isTopLevel);
 
 /*
  * CommandIsReadOnly: is an executable query read-only?
@@ -200,6 +201,7 @@ ClassifyUtilityCommandAsReadOnly(Node *parsetree)
 		case T_DropOwnedStmt:
 		case T_DropRoleStmt:
 		case T_DropStmt:
+		case T_DropInvalidIndexesStmt:
 		case T_DropSubscriptionStmt:
 		case T_DropTableSpaceStmt:
 		case T_DropUserMappingStmt:
@@ -969,6 +971,19 @@ standard_ProcessUtility(PlannedStmt *pstmt,
 			}
 			break;
 
+		case T_DropInvalidIndexesStmt:
+			{
+				DropInvalidIndexesStmt *stmt = (DropInvalidIndexesStmt *) parsetree;
+
+				if (EventTriggerSupportsObjectType(OBJECT_INDEX))
+					ProcessUtilitySlow(pstate, pstmt, queryString,
+									   context, params, queryEnv,
+									   dest, qc);
+				else
+					ExecDropInvalidIndexes(stmt, isTopLevel);
+			}
+			break;
+
 		case T_DropStmt:
 			{
 				DropStmt   *stmt = (DropStmt *) parsetree;
@@ -1782,6 +1797,12 @@ ProcessUtilitySlow(ParseState *pstate,
 				commandCollected = true;
 				break;
 
+			case T_DropInvalidIndexesStmt:
+				ExecDropInvalidIndexes((DropInvalidIndexesStmt *) parsetree, isTopLevel);
+				/* no commands stashed for DROP */
+				commandCollected = true;
+				break;
+
 			case T_DropStmt:
 				ExecDropStmt((DropStmt *) parsetree, isTopLevel);
 				/* no commands stashed for DROP */
@@ -2029,6 +2050,15 @@ ExecDropStmt(DropStmt *stmt, bool isTopLevel)
 	}
 }
 
+static void
+ExecDropInvalidIndexes(DropInvalidIndexesStmt *stmt, bool isTopLevel)
+{
+	if (stmt->concurrent)
+		PreventInTransactionBlock(isTopLevel,
+								  "DROP INVALID INDEXES CONCURRENTLY");
+
+	RemoveInvalidIndexes(stmt);
+}
 
 /*
  * UtilityReturnsTuples
@@ -2564,6 +2594,10 @@ CreateCommandTag(Node *parsetree)
 			tag = CMDTAG_IMPORT_FOREIGN_SCHEMA;
 			break;
 
+		case T_DropInvalidIndexesStmt:
+			tag = CMDTAG_DROP_INVALID_INDEXES;
+			break;
+
 		case T_DropStmt:
 			switch (((DropStmt *) parsetree)->removeType)
 			{
@@ -3523,6 +3557,10 @@ GetCommandLogLevel(Node *parsetree)
 			lev = LOGSTMT_DDL;
 			break;
 
+		case T_DropInvalidIndexesStmt:
+			lev = LOGSTMT_DDL;
+			break;
+
 		case T_DropdbStmt:
 			lev = LOGSTMT_DDL;
 			break;
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 46b9add0604..897e9609c24 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -1335,6 +1335,7 @@ static const pgsql_thing_t words_after_create[] = {
 	{"FUNCTION", NULL, NULL, Query_for_list_of_functions},
 	{"GROUP", Query_for_list_of_roles},
 	{"INDEX", NULL, NULL, &Query_for_list_of_indexes},
+	{"INVALID", NULL, NULL, NULL, NULL, THING_NO_CREATE | THING_NO_ALTER},
 	{"LANGUAGE", Query_for_list_of_languages},
 	{"LARGE OBJECT", NULL, NULL, NULL, NULL, THING_NO_CREATE | THING_NO_DROP},
 	{"MATERIALIZED VIEW", NULL, NULL, &Query_for_list_of_matviews},
@@ -4430,6 +4431,17 @@ match_previous_words(int pattern_id,
 	else if (Matches("DROP", "INDEX", "CONCURRENTLY", MatchAny))
 		COMPLETE_WITH("CASCADE", "RESTRICT");
 
+	/* DROP INVALID INDEXES */
+	else if (Matches("DROP", "INVALID"))
+		COMPLETE_WITH("INDEXES ON TABLE");
+	else if (Matches("DROP", "INVALID", "INDEXES"))
+		COMPLETE_WITH("ON TABLE");
+	else if (Matches("DROP", "INVALID", "INDEXES", "ON"))
+		COMPLETE_WITH("TABLE");
+	else if (Matches("DROP", "INVALID", "INDEXES", "ON", "TABLE"))
+		COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_tables);
+
+
 	/* DROP MATERIALIZED VIEW */
 	else if (Matches("DROP", "MATERIALIZED"))
 		COMPLETE_WITH("VIEW");
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index c3d8518cb62..70dff275d4b 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -32,6 +32,8 @@ extern TupleDesc BuildDescForRelation(const List *columns);
 
 extern void RemoveRelations(DropStmt *drop);
 
+extern void RemoveInvalidIndexes(DropInvalidIndexesStmt *drop);
+
 extern Oid	AlterTableLookupRelation(AlterTableStmt *stmt, LOCKMODE lockmode);
 
 extern void AlterTable(AlterTableStmt *stmt, LOCKMODE lockmode,
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 4133c404a6b..a9f1004f3ff 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -3444,6 +3444,19 @@ typedef struct DropStmt
 	bool		concurrent;		/* drop index concurrently? */
 } DropStmt;
 
+/* ----------------------
+ *		Drop invalid indexes on table Statement
+ * ----------------------
+ */
+
+typedef struct DropInvalidIndexesStmt
+{
+	NodeTag		type;
+	List	   *tablenames;		/* list of table names */
+	DropBehavior behavior;		/* RESTRICT or CASCADE behavior */
+	bool		concurrent;		/* drop index concurrently? */
+} DropInvalidIndexesStmt;
+
 /* ----------------------
  *				Truncate Table Statement
  * ----------------------
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index 51ead54f015..7754c7e6304 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -234,6 +234,7 @@ PG_KEYWORD("integer", INTEGER, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("intersect", INTERSECT, RESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("interval", INTERVAL, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("into", INTO, RESERVED_KEYWORD, AS_LABEL)
+PG_KEYWORD("invalid", INVALID_P, UNRESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("invoker", INVOKER, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("is", IS, TYPE_FUNC_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("isnull", ISNULL, TYPE_FUNC_NAME_KEYWORD, AS_LABEL)
diff --git a/src/include/tcop/cmdtaglist.h b/src/include/tcop/cmdtaglist.h
index befae5f6b4f..3080208045b 100644
--- a/src/include/tcop/cmdtaglist.h
+++ b/src/include/tcop/cmdtaglist.h
@@ -150,6 +150,7 @@ PG_CMDTAG(CMDTAG_DROP_FOREIGN_DATA_WRAPPER, "DROP FOREIGN DATA WRAPPER", true, f
 PG_CMDTAG(CMDTAG_DROP_FOREIGN_TABLE, "DROP FOREIGN TABLE", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_FUNCTION, "DROP FUNCTION", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_INDEX, "DROP INDEX", true, false, false)
+PG_CMDTAG(CMDTAG_DROP_INVALID_INDEXES, "DROP INVALID INDEXES", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_LANGUAGE, "DROP LANGUAGE", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_MATERIALIZED_VIEW, "DROP MATERIALIZED VIEW", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_OPERATOR, "DROP OPERATOR", true, false, false)
diff --git a/src/test/modules/test_misc/t/014_drop_invalid_indexes.pl b/src/test/modules/test_misc/t/014_drop_invalid_indexes.pl
new file mode 100644
index 00000000000..c8ee8acd9e8
--- /dev/null
+++ b/src/test/modules/test_misc/t/014_drop_invalid_indexes.pl
@@ -0,0 +1,79 @@
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::BackgroundPsql;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('ddl_drop_invalid_index');
+$node->init;
+$node->start;
+
+
+# Check if the extension injection_points is available, as it may be
+# possible that this script is run with installcheck, where the module
+# would not be installed by default.
+if (!$node->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+$node->safe_psql('postgres', q(CREATE TABLE tt (i INT PRIMARY KEY);));
+
+$node->safe_psql(
+	'postgres',
+	q{
+      INSERT INTO tt SELECT generate_series(1,10);
+    }
+);
+
+$node->safe_psql('postgres',
+	"SELECT injection_points_attach('index-build-after-am-callback', 'wait');");
+
+my $psql1 = $node->background_psql('postgres',  wait => 0);
+
+$psql1->query_safe(qq[SET application_name TO ddl_drop_invalid_index;]);
+
+# This pauses on the injection point while populating catcache list
+# for functions with name "foofunc"
+$psql1->query_until(
+	qr/starting_bg_psql/, q(
+   \echo starting_bg_psql
+   REINDEX TABLE CONCURRENTLY tt;
+));
+
+$node->safe_psql(
+    'postgres',
+	q{
+      select pg_cancel_backend(pid) from pg_stat_activity where application_name = 'ddl_drop_invalid_index';
+    }
+);
+
+
+is( $node->safe_psql(
+		'postgres',
+		"select count(1) from pg_index where not indisvalid;"),
+	'1',
+	'dropped invalid index');
+
+$node->safe_psql(
+    'postgres',
+	q{
+      DROP INVALID INDEXES ON TABLE tt;
+    }
+);
+
+is( $node->safe_psql(
+		'postgres',
+		"select count(1) from pg_index where not indisvalid;"),
+	'0',
+	'dropped invalid index');
+
+
+done_testing();
+
+
+
+
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3a2720fb5f9..435c041ab1d 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -707,6 +707,7 @@ DropSubscriptionStmt
 DropTableSpaceStmt
 DropUserMappingStmt
 DropdbStmt
+DropInvalidIndexesStmt
 DumpComponents
 DumpId
 DumpOptions
-- 
2.43.0

