From ffc8ecbc7fd710e433d9dc028d06dc20c7613c1c 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 v4] Index pg_constraint.confrelid to avoid seqscans of the
 catalog

Four places look up the foreign keys that reference a relation by
scanning pg_constraint for rows whose confrelid matches.  There was no
index on confrelid, so each was a sequential scan of the whole catalog:

  - CloneFkReferenced()          (ATTACH PARTITION, PARTITION OF)
  - GetParentedForeignKeyRefs()  (DETACH PARTITION)
  - ATPrepChangePersistence()    (ALTER TABLE ... SET UNLOGGED)
  - heap_truncate_find_FKs()     (TRUNCATE, and ON COMMIT DELETE ROWS
                                  at commit)

That was cheap while pg_constraint stayed small.  Since not-null
constraints gained pg_constraint rows (commit 14e87ffa5c5), the catalog
holds a row per not-null column, and these scans grow with the total
number of constraints in the database.  With about a million
constraints, each costs some 50-90 ms; with an ON COMMIT DELETE ROWS
temporary table that has a foreign key or trigger, every commit of a
transaction that used temporary tables pays more than 30 ms.

Add a non-unique btree index on confrelid, like the existing one on
contypid, and use it in all four places.  As with contypid, the rows
that do not use the column (every constraint other than a foreign key)
are indexed under zero.

heap_truncate_find_FKs() takes a list of relations.  Probing the index
once per relation would lose to the old seqscan when the catalog is
small and the list is long (many ON COMMIT DELETE ROWS tables, or a
TRUNCATE of thousands of tables), so it does a single range scan of the
index from the smallest to the largest OID in the list instead, and
skips foreign keys that reference a relation outside the list, as the
seqscan did.  The rows of other constraint types sort before any
relation OID and are never visited.

Reported-by: Bernhard Wonisch <bernhard.wonisch@gmx.at>
Suggested-by: Tom Lane <tgl@sss.pgh.pa.us>
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
---
 src/backend/catalog/heap.c          | 39 ++++++++++++++++++++++++++---
 src/backend/commands/tablecmds.c    | 37 ++++++++++++++++-----------
 src/include/catalog/pg_constraint.h |  1 +
 3 files changed, 59 insertions(+), 18 deletions(-)

diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index f0a159c1b77..b0cd3e48901 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -3830,16 +3830,28 @@ heap_truncate_find_FKs(List *relationIds)
 	List	   *parent_cons;
 	ListCell   *cell;
 	ScanKeyData key;
+	ScanKeyData rangekey[2];
+	Oid			minoid;
+	Oid			maxoid;
 	Relation	fkeyRel;
 	SysScanDesc fkeyScan;
 	HeapTuple	tuple;
 	bool		restart;
 
+	if (relationIds == NIL)
+		return NIL;
+
 	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.  We do a single range scan of the index on
+	 * confrelid, from the smallest to the largest OID in our list.  Only
+	 * foreign keys have a nonzero confrelid, so the rows of all other
+	 * constraints sort before the range and are never visited; foreign keys
+	 * that fall inside the range but reference a relation not in our list are
+	 * skipped below.  Each pass costs one index probe for a single relation,
+	 * and never more than visiting each foreign key once however long the
+	 * list is.
 	 */
 	fkeyRel = table_open(ConstraintRelationId, AccessShareLock);
 
@@ -3847,8 +3859,27 @@ restart:
 	restart = false;
 	parent_cons = NIL;
 
-	fkeyScan = systable_beginscan(fkeyRel, InvalidOid, false,
-								  NULL, 0, NULL);
+	minoid = maxoid = linitial_oid(oids);
+	foreach(cell, oids)
+	{
+		Oid			relid = lfirst_oid(cell);
+
+		if (relid < minoid)
+			minoid = relid;
+		if (relid > maxoid)
+			maxoid = relid;
+	}
+	ScanKeyInit(&rangekey[0],
+				Anum_pg_constraint_confrelid,
+				BTGreaterEqualStrategyNumber, F_OIDGE,
+				ObjectIdGetDatum(minoid));
+	ScanKeyInit(&rangekey[1],
+				Anum_pg_constraint_confrelid,
+				BTLessEqualStrategyNumber, F_OIDLE,
+				ObjectIdGetDatum(maxoid));
+
+	fkeyScan = systable_beginscan(fkeyRel, ConstraintConfRelidIndexId, true,
+								  NULL, 2, rangekey);
 
 	while (HeapTupleIsValid(tuple = systable_getnext(fkeyScan)))
 	{
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0274d892f2e..a44ebe87edf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -11355,16 +11355,23 @@ CloneFkReferenced(Relation parentRel, Relation partitionRel)
 	ScanKeyInit(&key[0],
 				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);
+
+	/*
+	 * Look this up through the index on confrelid rather than seqscanning all
+	 * of pg_constraint.  That scan grew expensive once not-null constraints
+	 * started to have pg_constraint rows, making its cost scale with the
+	 * total number of constraints in the database.  Only foreign keys set
+	 * confrelid, so filtering on contype in the loop is just
+	 * belt-and-suspenders.
+	 */
+	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);
@@ -19562,8 +19569,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.  Both columns
+	 * are indexed, so this also determines which index to use.
 	 */
 	ScanKeyInit(&skey[0],
 				toLogged ? Anum_pg_constraint_conrelid :
@@ -19571,7 +19578,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)))
@@ -22672,16 +22680,17 @@ GetParentedForeignKeyRefs(Relation partition)
 	ScanKeyInit(&key[0],
 				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);
+	/* Use the index on confrelid; only FKs set it, so filter contype below */
+	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);
 
-- 
2.55.0

