From b5b972a4bb9a6b5383ab5ad3340fd87aa77f7d30 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 19:18:35 -0300
Subject: [PATCH v10] Index pg_constraint.confrelid to avoid seqscans of the
 catalog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Three places look up the foreign keys that reference one relation by
scanning all of pg_constraint for a matching confrelid:

  - CloneFkReferenced()          (ATTACH PARTITION, PARTITION OF, and
                                  so every partition pg_upgrade restores)
  - GetParentedForeignKeyRefs()  (DETACH PARTITION)
  - ATPrepChangePersistence()    (ALTER TABLE ... SET UNLOGGED)

That was cheap while pg_constraint stayed small.  Since not-null
constraints gained pg_constraint rows (commit 14e87ffa5c5), these scans
grow with the total number of constraints in the database, and
pg_upgrade pays one per partition it restores.

Add a non-unique btree index on confrelid, like the existing one on
contypid, and use it in those three places.  As with contypid, the rows
of constraints other than foreign keys are indexed under zero, so every
constraint created, including each not-null constraint, pays for one
more index insertion.

Check in stats.sql that each of the three lookups scans the new index.
The check reads this backend's counters for the current transaction,
so the tests running in parallel, which attach and detach partitions
too, cannot hide a revert.

heap_truncate_find_FKs() also scans pg_constraint by confrelid, but for
a whole list of relations.  Probing the index once per relation costs
more than one pass over a small catalog, so it keeps its seqscan.

Reported-by: Bernhard Wonisch <bernhard.wonisch@gmx.at>
Suggested-by: Tom Lane <tgl@sss.pgh.pa.us>
Reviewed-by: Álvaro Herrera <alvherre@kurilemu.de>
Discussion: https://postgr.es/m/trinity-08d3329b-f6b2-4c11-91d7-2198e50bf4fc-1790681922354@trinity-msg-rest-gmx-gmx-live-58cc8f554d-c56lm
Discussion: https://postgr.es/m/1298334.1790896057@sss.pgh.pa.us
Discussion: https://postgr.es/m/CAApHDvpxGcb-_ddd+qL+F=Vmv4d3WZNbUCi3WQyj0HcFnC9v1g@mail.gmail.com
---
 src/backend/catalog/heap.c          |  5 ++--
 src/backend/commands/tablecmds.c    | 46 ++++++++++++++++++-----------
 src/include/catalog/pg_constraint.h |  1 +
 src/test/regress/expected/stats.out | 32 ++++++++++++++++++++
 src/test/regress/sql/stats.sql      | 28 ++++++++++++++++++
 5 files changed, 92 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index ce3d3466126..cce8f2d139e 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -3848,8 +3848,9 @@ heap_truncate_find_FKs(List *relationIds)
 	oids = list_copy(relationIds);
 
 	/*
-	 * Must scan pg_constraint.  Right now, it is a seqscan because there is
-	 * no available index on confrelid.
+	 * Must scan pg_constraint.  It is a seqscan although confrelid is
+	 * indexed, because probing the index once for each relation in the list
+	 * costs more than one pass over a small catalog.
 	 */
 	fkeyRel = table_open(ConstraintRelationId, AccessShareLock);
 
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 97e16f3eb47..9939d8de5b1 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -11350,7 +11350,7 @@ CloneFkReferenced(Relation parentRel, Relation partitionRel)
 	AttrMap    *attmap;
 	ListCell   *cell;
 	SysScanDesc scan;
-	ScanKeyData key[2];
+	ScanKeyData key;
 	HeapTuple	tuple;
 	List	   *clone = NIL;
 	Relation	trigrel;
@@ -11365,19 +11365,23 @@ CloneFkReferenced(Relation parentRel, Relation partitionRel)
 	 * scan could return children first.)
 	 */
 	pg_constraint = table_open(ConstraintRelationId, RowShareLock);
-	ScanKeyInit(&key[0],
+	ScanKeyInit(&key,
 				Anum_pg_constraint_confrelid, BTEqualStrategyNumber,
 				F_OIDEQ, ObjectIdGetDatum(RelationGetRelid(parentRel)));
-	ScanKeyInit(&key[1],
-				Anum_pg_constraint_contype, BTEqualStrategyNumber,
-				F_CHAREQ, CharGetDatum(CONSTRAINT_FOREIGN));
-	/* This is a seqscan, as we don't have a usable index ... */
-	scan = systable_beginscan(pg_constraint, InvalidOid, true,
-							  NULL, 2, key);
+
+	/*
+	 * Scan pg_constraint using the index on confrelid, which is only set
+	 * nonzero by FK constraints.  Filtering on contype in the loop is
+	 * therefore just a safety check.
+	 */
+	scan = systable_beginscan(pg_constraint, ConstraintConfRelidIndexId, true,
+							  NULL, 1, &key);
 	while ((tuple = systable_getnext(scan)) != NULL)
 	{
 		Form_pg_constraint constrForm = (Form_pg_constraint) GETSTRUCT(tuple);
 
+		if (constrForm->contype != CONSTRAINT_FOREIGN)
+			continue;
 		clone = lappend_oid(clone, constrForm->oid);
 	}
 	systable_endscan(scan);
@@ -19575,8 +19579,8 @@ ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel, bool toLogged)
 	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
 
 	/*
-	 * Scan conrelid if changing to permanent, else confrelid.  This also
-	 * determines whether a useful index exists.
+	 * Scan conrelid if changing to permanent, else confrelid.  Each column
+	 * leads an index, so this also determines which index to use.
 	 */
 	ScanKeyInit(&skey[0],
 				toLogged ? Anum_pg_constraint_conrelid :
@@ -19584,7 +19588,8 @@ ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel, bool toLogged)
 				BTEqualStrategyNumber, F_OIDEQ,
 				ObjectIdGetDatum(RelationGetRelid(rel)));
 	scan = systable_beginscan(pg_constraint,
-							  toLogged ? ConstraintRelidTypidNameIndexId : InvalidOid,
+							  toLogged ? ConstraintRelidTypidNameIndexId :
+							  ConstraintConfRelidIndexId,
 							  true, NULL, 1, skey);
 
 	while (HeapTupleIsValid(tuple = systable_getnext(scan)))
@@ -22673,7 +22678,7 @@ GetParentedForeignKeyRefs(Relation partition)
 	Relation	pg_constraint;
 	HeapTuple	tuple;
 	SysScanDesc scan;
-	ScanKeyData key[2];
+	ScanKeyData key;
 	List	   *constraints = NIL;
 
 	/*
@@ -22687,19 +22692,24 @@ GetParentedForeignKeyRefs(Relation partition)
 
 	/* Search for constraints referencing this table */
 	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
-	ScanKeyInit(&key[0],
+	ScanKeyInit(&key,
 				Anum_pg_constraint_confrelid, BTEqualStrategyNumber,
 				F_OIDEQ, ObjectIdGetDatum(RelationGetRelid(partition)));
-	ScanKeyInit(&key[1],
-				Anum_pg_constraint_contype, BTEqualStrategyNumber,
-				F_CHAREQ, CharGetDatum(CONSTRAINT_FOREIGN));
 
-	/* XXX This is a seqscan, as we don't have a usable index */
-	scan = systable_beginscan(pg_constraint, InvalidOid, true, NULL, 2, key);
+	/*
+	 * Scan pg_constraint using the index on confrelid, which is only set
+	 * nonzero by FK constraints.  Filtering on contype in the loop is
+	 * therefore just a safety check.
+	 */
+	scan = systable_beginscan(pg_constraint, ConstraintConfRelidIndexId, true,
+							  NULL, 1, &key);
 	while ((tuple = systable_getnext(scan)) != NULL)
 	{
 		Form_pg_constraint constrForm = (Form_pg_constraint) GETSTRUCT(tuple);
 
+		if (constrForm->contype != CONSTRAINT_FOREIGN)
+			continue;
+
 		/*
 		 * We only need to process constraints that are part of larger ones.
 		 */
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index e8d27546ed9..47d57f29488 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -185,6 +185,7 @@ DECLARE_UNIQUE_INDEX(pg_constraint_conrelid_contypid_conname_index, 2665, Constr
 DECLARE_INDEX(pg_constraint_contypid_index, 2666, ConstraintTypidIndexId, pg_constraint, btree(contypid oid_ops));
 DECLARE_UNIQUE_INDEX_PKEY(pg_constraint_oid_index, 2667, ConstraintOidIndexId, pg_constraint, btree(oid oid_ops));
 DECLARE_INDEX(pg_constraint_conparentid_index, 2579, ConstraintParentIndexId, pg_constraint, btree(conparentid oid_ops));
+DECLARE_INDEX(pg_constraint_confrelid_index, 9370, ConstraintConfRelidIndexId, pg_constraint, btree(confrelid oid_ops));
 
 MAKE_SYSCACHE(CONSTROID, pg_constraint_oid_index, 16);
 
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index 8b15471248b..9a7a8c50e76 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -2154,4 +2154,36 @@ SELECT fastpath_exceeded > :backend_fastpath_exceeded_before
 (1 row)
 
 DROP TABLE part_test;
+-- Test that the lookups of foreign keys by referenced relation use
+-- pg_constraint_confrelid_index.  The counter is this backend's, for the
+-- current transaction, so other sessions cannot change the result.
+CREATE TABLE confrelid_pk (id int PRIMARY KEY) PARTITION BY LIST (id);
+CREATE TABLE confrelid_fk (pid int REFERENCES confrelid_pk);
+CREATE TABLE confrelid_ul (a int);
+CREATE FUNCTION confrelid_idx_scan() RETURNS bigint LANGUAGE sql AS
+  $$SELECT pg_stat_get_xact_idx_numscans('pg_constraint_confrelid_index'::regclass)$$;
+BEGIN;
+SELECT confrelid_idx_scan() AS confrelid_scans \gset
+-- CloneFkReferenced()
+CREATE TABLE confrelid_pk1 PARTITION OF confrelid_pk FOR VALUES IN (1);
+SELECT confrelid_idx_scan() > :confrelid_scans AS clone_fk_referenced,
+       confrelid_idx_scan() AS confrelid_scans \gset
+-- GetParentedForeignKeyRefs()
+ALTER TABLE confrelid_pk DETACH PARTITION confrelid_pk1;
+SELECT confrelid_idx_scan() > :confrelid_scans AS get_parented_fk_refs,
+       confrelid_idx_scan() AS confrelid_scans \gset
+-- ATPrepChangePersistence()
+ALTER TABLE confrelid_ul SET UNLOGGED;
+SELECT confrelid_idx_scan() > :confrelid_scans AS change_persistence \gset
+COMMIT;
+SELECT :'clone_fk_referenced' AS clone_fk_referenced,
+       :'get_parented_fk_refs' AS get_parented_fk_refs,
+       :'change_persistence' AS change_persistence;
+ clone_fk_referenced | get_parented_fk_refs | change_persistence 
+---------------------+----------------------+--------------------
+ t                   | t                    | t
+(1 row)
+
+DROP TABLE confrelid_fk, confrelid_pk, confrelid_pk1, confrelid_ul;
+DROP FUNCTION confrelid_idx_scan();
 -- End of Stats Test
diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql
index 674637e172b..3fbe32274fe 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -1070,4 +1070,32 @@ SELECT fastpath_exceeded > :backend_fastpath_exceeded_before
 
 DROP TABLE part_test;
 
+-- Test that the lookups of foreign keys by referenced relation use
+-- pg_constraint_confrelid_index.  The counter is this backend's, for the
+-- current transaction, so other sessions cannot change the result.
+CREATE TABLE confrelid_pk (id int PRIMARY KEY) PARTITION BY LIST (id);
+CREATE TABLE confrelid_fk (pid int REFERENCES confrelid_pk);
+CREATE TABLE confrelid_ul (a int);
+CREATE FUNCTION confrelid_idx_scan() RETURNS bigint LANGUAGE sql AS
+  $$SELECT pg_stat_get_xact_idx_numscans('pg_constraint_confrelid_index'::regclass)$$;
+BEGIN;
+SELECT confrelid_idx_scan() AS confrelid_scans \gset
+-- CloneFkReferenced()
+CREATE TABLE confrelid_pk1 PARTITION OF confrelid_pk FOR VALUES IN (1);
+SELECT confrelid_idx_scan() > :confrelid_scans AS clone_fk_referenced,
+       confrelid_idx_scan() AS confrelid_scans \gset
+-- GetParentedForeignKeyRefs()
+ALTER TABLE confrelid_pk DETACH PARTITION confrelid_pk1;
+SELECT confrelid_idx_scan() > :confrelid_scans AS get_parented_fk_refs,
+       confrelid_idx_scan() AS confrelid_scans \gset
+-- ATPrepChangePersistence()
+ALTER TABLE confrelid_ul SET UNLOGGED;
+SELECT confrelid_idx_scan() > :confrelid_scans AS change_persistence \gset
+COMMIT;
+SELECT :'clone_fk_referenced' AS clone_fk_referenced,
+       :'get_parented_fk_refs' AS get_parented_fk_refs,
+       :'change_persistence' AS change_persistence;
+DROP TABLE confrelid_fk, confrelid_pk, confrelid_pk1, confrelid_ul;
+DROP FUNCTION confrelid_idx_scan();
+
 -- End of Stats Test
