From d9a1a699113bcb3e20ca66f828ceaf689cf0a6ef Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Tue, 27 Oct 2026 01:07:13 -0300
Subject: [PATCH v9 2/2] Use the confrelid index in heap_truncate_find_FKs()
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

heap_truncate_find_FKs(), used by TRUNCATE and by ON COMMIT DELETE ROWS
at commit, still scanned all of pg_constraint.  It takes a list of
relations, so probe the index once for each of them: that visits only
the foreign keys that reference the list, however far apart the OIDs
of the relations are.  When it climbs to a partitioned parent and
restarts, it only looks up the relations added since the previous pass.

TRUNCATE ... CASCADE called heap_truncate_find_FKs() once per level of
references with its whole, growing list of relations, which with a
probe per relation would look up every relation again at every level.
Pass it only the relations added at the previous level instead; the
referencers of the others are already in the list.  Add a test where
the cascade reaches a cycle of foreign keys, the case in which that
loop has to skip relations it already has.

Each probe has a fixed cost, so in a nearly empty database a long list,
such as many ON COMMIT DELETE ROWS temporary tables with triggers, costs
more than the old seqscan.

Also check in stats.sql that the lookup scans the new index.

Reviewed-by: Álvaro Herrera <alvherre@kurilemu.de>
Discussion: https://postgr.es/m/CAApHDvpxGcb-_ddd+qL+F=Vmv4d3WZNbUCi3WQyj0HcFnC9v1g@mail.gmail.com
---
 src/backend/catalog/heap.c             | 69 +++++++++++++++-----------
 src/backend/commands/tablecmds.c       | 18 +++++--
 src/test/regress/expected/stats.out    | 17 +++++--
 src/test/regress/expected/truncate.out | 26 ++++++++++
 src/test/regress/sql/stats.sql         |  6 +++
 src/test/regress/sql/truncate.sql      | 18 +++++++
 6 files changed, 117 insertions(+), 37 deletions(-)

diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index f0a159c1b77..29ce2ec9f35 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -3834,12 +3834,15 @@ heap_truncate_find_FKs(List *relationIds)
 	SysScanDesc fkeyScan;
 	HeapTuple	tuple;
 	bool		restart;
+	int			nprobed = 0;
 
 	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.  Look up each relation in our list through the
+	 * index on confrelid, which is only set nonzero by FK constraints, so we
+	 * visit just the foreign keys that reference our list.  A restart only
+	 * needs to look up the relations added since the previous pass.
 	 */
 	fkeyRel = table_open(ConstraintRelationId, AccessShareLock);
 
@@ -3847,40 +3850,46 @@ restart:
 	restart = false;
 	parent_cons = NIL;
 
-	fkeyScan = systable_beginscan(fkeyRel, InvalidOid, false,
-								  NULL, 0, NULL);
-
-	while (HeapTupleIsValid(tuple = systable_getnext(fkeyScan)))
+	for_each_from(cell, oids, nprobed)
 	{
-		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(tuple);
+		ScanKeyInit(&key,
+					Anum_pg_constraint_confrelid,
+					BTEqualStrategyNumber, F_OIDEQ,
+					ObjectIdGetDatum(lfirst_oid(cell)));
 
-		/* Not a foreign key */
-		if (con->contype != CONSTRAINT_FOREIGN)
-			continue;
+		fkeyScan = systable_beginscan(fkeyRel, ConstraintConfRelidIndexId,
+									  true, NULL, 1, &key);
 
-		/* Not referencing one of our list of tables */
-		if (!list_member_oid(oids, con->confrelid))
-			continue;
+		while (HeapTupleIsValid(tuple = systable_getnext(fkeyScan)))
+		{
+			Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(tuple);
 
-		/*
-		 * If this constraint has a parent constraint which we have not seen
-		 * yet, keep track of it for the second loop, below.  Tracking parent
-		 * constraints allows us to climb up to the top-level constraint and
-		 * look for all possible relations referencing the partitioned table.
-		 */
-		if (OidIsValid(con->conparentid) &&
-			!list_member_oid(parent_cons, con->conparentid))
-			parent_cons = lappend_oid(parent_cons, con->conparentid);
+			/* Not a foreign key */
+			if (con->contype != CONSTRAINT_FOREIGN)
+				continue;
 
-		/*
-		 * Add referencer to result, unless present in input list.  (Don't
-		 * worry about dupes: we'll fix that below).
-		 */
-		if (!list_member_oid(relationIds, con->conrelid))
-			result = lappend_oid(result, con->conrelid);
-	}
+			/*
+			 * If this constraint has a parent constraint which we have not
+			 * seen yet, keep track of it for the second loop, below. Tracking
+			 * parent constraints allows us to climb up to the top-level
+			 * constraint and look for all possible relations referencing the
+			 * partitioned table.
+			 */
+			if (OidIsValid(con->conparentid) &&
+				!list_member_oid(parent_cons, con->conparentid))
+				parent_cons = lappend_oid(parent_cons, con->conparentid);
 
-	systable_endscan(fkeyScan);
+			/*
+			 * Add referencer to result, unless present in input list.  (Don't
+			 * worry about dupes: we'll fix that below).
+			 */
+			if (!list_member_oid(relationIds, con->conrelid))
+				result = lappend_oid(result, con->conrelid);
+		}
+
+		systable_endscan(fkeyScan);
+	}
+	nprobed = list_length(oids);
 
 	/*
 	 * Process each parent constraint we found to add the list of referenced
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 60207049a35..81d568954a8 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2041,19 +2041,28 @@ ExecuteTruncateGuts(List *explicit_rels,
 	rels = list_copy(explicit_rels);
 	if (behavior == DROP_CASCADE)
 	{
-		for (;;)
+		List	   *pending = list_copy(relids);
+
+		/*
+		 * Each iteration only needs the referencers of the rels added by the
+		 * previous one; those of older rels were already added.
+		 */
+		while (pending != NIL)
 		{
 			List	   *newrelids;
 
-			newrelids = heap_truncate_find_FKs(relids);
-			if (newrelids == NIL)
-				break;			/* nothing else to add */
+			newrelids = heap_truncate_find_FKs(pending);
+			list_free(pending);
+			pending = NIL;
 
 			foreach(cell, newrelids)
 			{
 				Oid			relid = lfirst_oid(cell);
 				Relation	rel;
 
+				if (list_member_oid(relids, relid))
+					continue;
+
 				rel = table_open(relid, AccessExclusiveLock);
 				ereport(NOTICE,
 						(errmsg("truncate cascades to table \"%s\"",
@@ -2063,6 +2072,7 @@ ExecuteTruncateGuts(List *explicit_rels,
 				truncate_check_activity(rel);
 				rels = lappend(rels, rel);
 				relids = lappend_oid(relids, relid);
+				pending = lappend_oid(pending, relid);
 
 				/* Log this relation only if needed for logical decoding */
 				if (RelationIsLogicallyLogged(rel))
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index 8cecd41bda8..acc317b86ea 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -2190,6 +2190,16 @@ SELECT pg_stat_force_next_flush();
 
 SELECT confrelid_idx_scan() > :confrelid_scans AS get_parented_fk_refs,
        confrelid_idx_scan() AS confrelid_scans \gset
+-- heap_truncate_find_FKs()
+TRUNCATE confrelid_pk, confrelid_fk;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT confrelid_idx_scan() > :confrelid_scans AS heap_truncate_find_fks,
+       confrelid_idx_scan() AS confrelid_scans \gset
 -- ATPrepChangePersistence()
 ALTER TABLE confrelid_ul SET UNLOGGED;
 SELECT pg_stat_force_next_flush();
@@ -2201,10 +2211,11 @@ SELECT pg_stat_force_next_flush();
 SELECT confrelid_idx_scan() > :confrelid_scans AS change_persistence \gset
 SELECT :'clone_fk_referenced' AS clone_fk_referenced,
        :'get_parented_fk_refs' AS get_parented_fk_refs,
+       :'heap_truncate_find_fks' AS heap_truncate_find_fks,
        :'change_persistence' AS change_persistence;
- clone_fk_referenced | get_parented_fk_refs | change_persistence 
----------------------+----------------------+--------------------
- t                   | t                    | t
+ clone_fk_referenced | get_parented_fk_refs | heap_truncate_find_fks | change_persistence 
+---------------------+----------------------+------------------------+--------------------
+ t                   | t                    | t                      | t
 (1 row)
 
 DROP TABLE confrelid_fk, confrelid_pk, confrelid_pk1, confrelid_ul;
diff --git a/src/test/regress/expected/truncate.out b/src/test/regress/expected/truncate.out
index 1e88e867bf2..5d83da6e8a2 100644
--- a/src/test/regress/expected/truncate.out
+++ b/src/test/regress/expected/truncate.out
@@ -138,6 +138,32 @@ SELECT * FROM trunc_e;
 (0 rows)
 
 DROP TABLE truncate_a,trunc_c,trunc_b,trunc_d,trunc_e CASCADE;
+-- TRUNCATE ... CASCADE reaching a cycle of foreign keys: trunc_cyc_b is
+-- found through trunc_cyc_a, and trunc_cyc_a references it back
+CREATE TABLE trunc_cyc_r (id int PRIMARY KEY);
+CREATE TABLE trunc_cyc_a (id int PRIMARY KEY, r int REFERENCES trunc_cyc_r, b int);
+CREATE TABLE trunc_cyc_b (id int PRIMARY KEY, a int REFERENCES trunc_cyc_a);
+ALTER TABLE trunc_cyc_a ADD FOREIGN KEY (b) REFERENCES trunc_cyc_b;
+INSERT INTO trunc_cyc_r VALUES (1);
+INSERT INTO trunc_cyc_a VALUES (1, 1, NULL);
+INSERT INTO trunc_cyc_b VALUES (1, 1);
+UPDATE trunc_cyc_a SET b = 1;
+TRUNCATE TABLE trunc_cyc_r CASCADE;
+NOTICE:  truncate cascades to table "trunc_cyc_a"
+NOTICE:  truncate cascades to table "trunc_cyc_b"
+SELECT count(*) FROM trunc_cyc_r
+   UNION ALL
+ SELECT count(*) FROM trunc_cyc_a
+   UNION ALL
+ SELECT count(*) FROM trunc_cyc_b;
+ count 
+-------
+     0
+     0
+     0
+(3 rows)
+
+DROP TABLE trunc_cyc_r, trunc_cyc_a, trunc_cyc_b;
 -- Test TRUNCATE with inheritance
 CREATE TABLE trunc_f (col1 integer primary key);
 INSERT INTO trunc_f VALUES (1);
diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql
index dbd12527e0a..b627b38f233 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -1091,12 +1091,18 @@ ALTER TABLE confrelid_pk DETACH PARTITION confrelid_pk1;
 SELECT pg_stat_force_next_flush();
 SELECT confrelid_idx_scan() > :confrelid_scans AS get_parented_fk_refs,
        confrelid_idx_scan() AS confrelid_scans \gset
+-- heap_truncate_find_FKs()
+TRUNCATE confrelid_pk, confrelid_fk;
+SELECT pg_stat_force_next_flush();
+SELECT confrelid_idx_scan() > :confrelid_scans AS heap_truncate_find_fks,
+       confrelid_idx_scan() AS confrelid_scans \gset
 -- ATPrepChangePersistence()
 ALTER TABLE confrelid_ul SET UNLOGGED;
 SELECT pg_stat_force_next_flush();
 SELECT confrelid_idx_scan() > :confrelid_scans AS change_persistence \gset
 SELECT :'clone_fk_referenced' AS clone_fk_referenced,
        :'get_parented_fk_refs' AS get_parented_fk_refs,
+       :'heap_truncate_find_fks' AS heap_truncate_find_fks,
        :'change_persistence' AS change_persistence;
 DROP TABLE confrelid_fk, confrelid_pk, confrelid_pk1, confrelid_ul;
 DROP FUNCTION confrelid_idx_scan();
diff --git a/src/test/regress/sql/truncate.sql b/src/test/regress/sql/truncate.sql
index 54f26e3077a..0a902bd3c84 100644
--- a/src/test/regress/sql/truncate.sql
+++ b/src/test/regress/sql/truncate.sql
@@ -78,6 +78,24 @@ SELECT * FROM trunc_e;
 
 DROP TABLE truncate_a,trunc_c,trunc_b,trunc_d,trunc_e CASCADE;
 
+-- TRUNCATE ... CASCADE reaching a cycle of foreign keys: trunc_cyc_b is
+-- found through trunc_cyc_a, and trunc_cyc_a references it back
+CREATE TABLE trunc_cyc_r (id int PRIMARY KEY);
+CREATE TABLE trunc_cyc_a (id int PRIMARY KEY, r int REFERENCES trunc_cyc_r, b int);
+CREATE TABLE trunc_cyc_b (id int PRIMARY KEY, a int REFERENCES trunc_cyc_a);
+ALTER TABLE trunc_cyc_a ADD FOREIGN KEY (b) REFERENCES trunc_cyc_b;
+INSERT INTO trunc_cyc_r VALUES (1);
+INSERT INTO trunc_cyc_a VALUES (1, 1, NULL);
+INSERT INTO trunc_cyc_b VALUES (1, 1);
+UPDATE trunc_cyc_a SET b = 1;
+TRUNCATE TABLE trunc_cyc_r CASCADE;
+SELECT count(*) FROM trunc_cyc_r
+   UNION ALL
+ SELECT count(*) FROM trunc_cyc_a
+   UNION ALL
+ SELECT count(*) FROM trunc_cyc_b;
+DROP TABLE trunc_cyc_r, trunc_cyc_a, trunc_cyc_b;
+
 -- Test TRUNCATE with inheritance
 
 CREATE TABLE trunc_f (col1 integer primary key);
