Hi David,

On Sat, Oct 24, 2026 at 5:35 PM Álvaro Herrera
<[email protected]> wrote:
> Yeah, we have a patch to add this already; see the thread at
> https://postgr.es/m/trinity-08d3329b-f6b2-4c11-91d7-2198e50bf4fc-1790681922354@trinity-msg-rest-gmx-gmx-live-58cc8f554d-c56lm

That is CF 7365, which started from Bernhard Wonisch's report of
ATTACH PARTITION slowing down as pg_constraint grows.  v6 is attached,
rebased on master dfd7ede529f; 0001 has the same code as the version
posted in that thread.  It adds a non-unique btree index on confrelid,
like the one on contypid, and uses it in the four places that look up
foreign keys by referenced relation.

Three of them are the ones your patch changes.  The fourth is
heap_truncate_find_FKs(), used by TRUNCATE and by ON COMMIT DELETE
ROWS at commit.  It takes a list of relations, so it does one range
scan of the index from the lowest to the highest OID in the list
instead of one probe per relation.  With 95,759 constraints:
- 100 TRUNCATEs of a referenced table: master does 100 seq scans of
  pg_constraint (9.6M rows read) and 2.9G instructions; v6 does no
  seq scan and 0.20G.
- 100 commits that touch an ON COMMIT DELETE ROWS temp table with a
  trigger: 2.8G instructions on master, 0.084G on v6 (0.081G for the
  same table without a trigger, which skips the lookup).

Your repro with v6, at 1/10 of its scale (3,200 partitions, 95,756
constraints), old cluster on master, three upgrades per side:
- master: 3,200 seq scans of pg_constraint in the restored database,
  one per ATTACH PARTITION; 187.7G instructions for all of pg_upgrade.
- v6: no seq scan, 3,200 scans of the new index; 100.1G instructions.
At 2/10 it is 556.2G against 206.6G: from 1/10 to 2/10 master grows
2.96x and v6 2.06x.  After each v6 upgrade, from master or from 18.6,
amcheck with heapallindexed is clean on all pg_constraint indexes.
I have not re-timed it, so there is no wall-clock number yet.

On the trade-offs:
- Write cost: creating a NOT NULL constraint takes about 7.6k more
  instructions, +5%.  WAL when creating constraints grows 1-4%,
  about 120 bytes per pg_constraint row (measured on 15 Oct).
- Size: 16 kB per database after initdb; 6.3-6.8 MB next to a 342 MB
  pg_constraint with about 1M constraints (15 Oct).
- Rows that are not foreign keys go in the index under zero, as with
  contypid.  None of the four lookups visits them, since they all ask
  for a nonzero confrelid.

0001 stands alone.  0002 is optional: a check in stats.sql that each
of the four lookups adds a scan of the new index.  Reverting any one
call site to a heap scan fails exactly its own check.  The OID (9370)
is in the development range for renumbering at commit, and catversion
is left to the committer.

Regards,
Manu
From 7d978d6741babe92cab56fccbd1b56eccf2e072c Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <[email protected]>
Date: Sun, 4 Oct 2026 19:18:35 -0300
Subject: [PATCH v6 1/2] 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, and
                                  so every partition pg_upgrade restores)
  - 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.  pg_upgrade pays one such scan
per partition it restores, and with an ON COMMIT DELETE ROWS temporary
table that has a foreign key or trigger, so does every commit of a
transaction that used temporary tables.

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 <[email protected]>
Suggested-by: Tom Lane <[email protected]>
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/[email protected]
Discussion: https://postgr.es/m/CAApHDvpxGcb-_ddd+qL+F=vmv4d3wznbuci3wqyj0hcfnc9...@mail.gmail.com
---
 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 97e16f3eb47..091b5831c85 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -11368,16 +11368,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);
@@ -19575,8 +19582,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 :
@@ -19584,7 +19591,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)))
@@ -22690,16 +22698,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);
 
From 437770dd62f606610e1563f201bdf1748a890202 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <[email protected]>
Date: Thu, 15 Oct 2026 23:50:00 -0300
Subject: [PATCH v6 2/2] Test that the confrelid lookups use the new index

The four lookups by confrelid are covered functionally by the existing
tests, but nothing fails if one of them goes back to a sequential scan
of pg_constraint.  Check in stats.sql that each of them adds an index
scan of pg_constraint_confrelid_index.  The check only asks for the
counter to grow, so concurrent sessions cannot make it fail; reverting
any one of the four call sites to a heap scan flips exactly its column.
---
 src/test/regress/expected/stats.out | 66 +++++++++++++++++++++++++++++
 src/test/regress/sql/stats.sql      | 37 ++++++++++++++++
 2 files changed, 103 insertions(+)

diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index 8b15471248b..4522661e69f 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -2154,4 +2154,70 @@ 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.  Each statement below must add an index
+-- scan; other sessions can only add more, so this cannot fail spuriously.
+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 idx_scan FROM pg_stat_sys_indexes
+    WHERE indexrelname = 'pg_constraint_confrelid_index'$$;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT confrelid_idx_scan() AS confrelid_scans \gset
+-- CloneFkReferenced()
+CREATE TABLE confrelid_pk1 PARTITION OF confrelid_pk FOR VALUES IN (1);
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+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 pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT confrelid_idx_scan() > :confrelid_scans AS get_parented_fk_refs,
+       confrelid_idx_scan() AS confrelid_scans \gset
+-- heap_truncate_find_FKs()
+TRUNCATE confrelid_pk1;
+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();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+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 | heap_truncate_find_fks | change_persistence 
+---------------------+----------------------+------------------------+--------------------
+ t                   | 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..5953c5ae5bf 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -1070,4 +1070,41 @@ 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.  Each statement below must add an index
+-- scan; other sessions can only add more, so this cannot fail spuriously.
+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 idx_scan FROM pg_stat_sys_indexes
+    WHERE indexrelname = 'pg_constraint_confrelid_index'$$;
+SELECT pg_stat_force_next_flush();
+SELECT confrelid_idx_scan() AS confrelid_scans \gset
+-- CloneFkReferenced()
+CREATE TABLE confrelid_pk1 PARTITION OF confrelid_pk FOR VALUES IN (1);
+SELECT pg_stat_force_next_flush();
+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 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_pk1;
+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();
+
 -- End of Stats Test
CF 7365 v6 -- counts on master dfd7ede529f, without and with v6-0001

Counts only, no times: pg_stat counters and user-space instructions
(perf stat -e instructions:u).  Builds: gcc -O2 -g, no cassert.  Three
runs per side, alternating order; every run is listed.  All numbers
come from repro-counts.sh.txt (attached), run on 26 Oct 2026.

1. pg_upgrade of pg_upgrade_catalog_repro.sql (David's script)

Old cluster built with master; new cluster is master or v6.  The
script's arguments are scaled down; max_locks_per_transaction = 4096
on both clusters.  Instructions cover pg_upgrade and every process it
starts (both servers, pg_dump, pg_restore).  Scan counters are read in
the restored database right after the upgrade.

scale 1/10: build_catalog_bloat(32, 100, 10, 397, 200)
  3,200 partitions; 95,756 pg_constraint rows
  (n 85,947, p 3,295, c 3,234, f 3,232, u 48)

          pg_constraint  confrelid_index  instructions (billions)
          seq_scan       idx_scan         run 1    run 2    run 3
  master  3200           -                187.80   187.54   187.77
  v6      0              3200             100.15   100.15   100.15

scale 2/10: build_catalog_bloat(64, 100, 10, 794, 200)
  6,400 partitions; 191,316 pg_constraint rows

  master  6400           -                556.21   556.20   556.23
  v6      0              6400             206.57   206.62   206.60

master / v6: 1.87x at 1/10, 2.69x at 2/10.  From 1/10 to 2/10, v6
grows 2.06x and master 2.96x.  The full scale (957k rows) was not run.
One seq scan per ATTACH PARTITION on master: 3200 and 6400 are the
partition counts.

Old cluster on 18.6 instead (1/10, one run each): to master, 3200 seq
scans and 378.86 billion instructions; to v6, 0 seq scans, 3200 index
scans and 291.49 billion.

After every v6 upgrade, from master or from 18.6:
bt_index_parent_check(heapallindexed) on pg_constraint_confrelid_index
and bt_index_check(heapallindexed) on all 6 pg_constraint indexes: ok.


2. heap_truncate_find_FKs(): TRUNCATE and ON COMMIT DELETE ROWS

In the 1/10 cluster after the upgrade (95,759 pg_constraint rows).
One single-user backend per workload under perf stat; each workload
runs its statement 100 times, one transaction each.  The controls do
the same work without reaching the FK lookup.

                                  seq_scan  rows read  instr. (millions)
                                                       run 1  run 2  run 3
TRUNCATE pk, fk (fk references pk)
  master                          100       9,575,900  2915.4 2915.4 2915.1
  v6                              0         0           203.1  203.0  203.0
control: TRUNCATE of a table with no triggers
  master                          0         0           122.1  121.8  121.7
  v6                              0         0           122.0  121.5  121.7
INSERT into an ON COMMIT DELETE ROWS temp table with a trigger
  master                          100       9,575,900  2817.3 2817.3 2817.5
  v6                              0         0            84.2   84.3   84.5
control: same temp table, no trigger
  master                          0         0            80.8   80.8   80.7
  v6                              0         0            80.7   80.7   80.7

Above the control, per statement: TRUNCATE 27.9M instructions on
master, 0.81M on v6; commit 27.4M on master, 0.04M on v6.

3. Write cost of the index: NOT NULL constraints

Fresh initdb, single-user backend: CREATE TABLE of 5 tables x 200
NOT NULL columns, minus the same tables with nullable columns.

          instructions for 1000 NOT NULL constraints (millions)
          run 1    run 2    run 3
  master  151.87   151.93   151.97
  v6      159.58   159.54   159.43

About 7.6k more instructions per NOT NULL constraint (+5.0%); 7.5k
to 7.7k when each run is paired with the master run of the same number.

4. From the 15 Oct runs on v4 (same code as v6, other hunk offsets)

WAL when creating constraints: +1% to +4%, about 120 bytes per
pg_constraint row (WAL bytes are exact).  Index size: 16 kB per
database after initdb; 6.3-6.8 MB next to a 342 MB pg_constraint with
about 1M constraints.  pg_upgrade from master with 820 constraints of
all kinds: amcheck (heapallindexed) clean on all catalog indexes.

5. v6-0002 (regression test), mutation check

Reverting one call site at a time to a heap scan (InvalidOid) makes
stats.sql fail on exactly that site's column:

  heap_truncate_find_FKs     t t f t
  CloneFkReferenced          f t t t
  ATPrepChangePersistence    t t t f
  GetParentedForeignKeyRefs  t f t t

make check with v6 (cassert): 239/239.

Not measured: wall-clock time of pg_upgrade at any scale.
#!/usr/bin/env bash
# Counts, not times, for the confrelid index (CF 7365), master vs master+v6.
#
#   upgrade:  David Rowley's pg_upgrade_catalog_repro.sql, at 1/10 of its
#             default scale, upgraded with pg_upgrade; pg_constraint scan
#             counters of the restored database and user-space instructions
#             of pg_upgrade and every process it starts (perf stat).
#   truncate: 100 x TRUNCATE of a referenced table, and 100 commits that
#             touch an ON COMMIT DELETE ROWS temp table with a trigger,
#             each next to a control that skips the FK lookup; run in the
#             upgraded cluster, single-user backend under perf stat.
#   notnull:  instructions to create 1000 NOT NULL constraints, minus the
#             same tables with nullable columns, in a fresh cluster.
#
# usage (each stage is independent; N runs = call it N times):
#   OLD_BIN=<master>/bin MASTER_BIN=<master>/bin V6_BIN=<v6>/bin \
#   REPRO_SQL=<path>/pg_upgrade_catalog_repro.sql WORK=<scratch dir> \
#   bash repro-counts.sh.txt setup
#   bash repro-counts.sh.txt upgrade  master|v6 <run>
#   bash repro-counts.sh.txt truncate master|v6 <run>     (after "upgrade" of 
that side)
#   bash repro-counts.sh.txt notnull  master|v6 <run>
# Scale: PARENTS=32 PARTS=100 PLAIN=397 NNCOLS=200 (script defaults are
# 323/100/3970/200); the old cluster is built once, with OLD_BIN.
set -euo pipefail
: "${WORK:?}" "${OLD_BIN:?}" "${MASTER_BIN:?}" "${V6_BIN:?}"
PARENTS=${PARENTS:-32} PARTS=${PARTS:-100} PLAIN=${PLAIN:-397} 
NNCOLS=${NNCOLS:-200}
PORT=${PORT:-55432}
mkdir -p "$WORK"
side_bin() { case $1 in master) echo "$MASTER_BIN";; v6) echo "$V6_BIN";; *) 
exit 2;; esac; }
instr() { awk -F, '$3 ~ /^instructions/ {print $1}' "$1"; }
# one value from a single-user backend's output: val <file> <column>
val() { sed -n "s/^[[:space:]]*[0-9]*: $2 = \"\([^\"]*\)\".*/\1/p" "$1" | tail 
-1; }

case ${1:?stage} in
setup)
  : "${REPRO_SQL:?}"
  n=$(grep -c '^CALL public\.build_catalog_bloat();' "$REPRO_SQL" || true)
  [ "$n" = 1 ] || { echo "expected one CALL line in $REPRO_SQL"; exit 1; }
  rm -rf "$WORK/old"
  "$OLD_BIN/initdb" -D "$WORK/old" -U postgres -A trust >/dev/null
  cat >>"$WORK/old/postgresql.conf" <<EOF
max_locks_per_transaction = 4096
fsync = off
synchronous_commit = off
autovacuum = off
listen_addresses = ''
EOF
  "$OLD_BIN/pg_ctl" -D "$WORK/old" -l "$WORK/old.log" -w -o "-p $PORT -k $WORK" 
start >/dev/null
  "$OLD_BIN/createdb" -h "$WORK" -p $PORT -U postgres test
  sed "s/^CALL public\.build_catalog_bloat();/CALL 
public.build_catalog_bloat($PARENTS, $PARTS, 10, $PLAIN, $NNCOLS);/" \
    "$REPRO_SQL" | "$OLD_BIN/psql" -h "$WORK" -p $PORT -U postgres -d test -X 
-q \
    -v ON_ERROR_STOP=1 -f - >"$WORK/setup.out" 2>&1
  echo "old cluster: scale $PARENTS/$PARTS/10/$PLAIN/$NNCOLS"
  "$OLD_BIN/psql" -h "$WORK" -p $PORT -U postgres -d test -X -qtA -F' ' -c \
    "SELECT coalesce(contype::text, 'all'), count(*) FROM pg_constraint
     GROUP BY ROLLUP (contype) ORDER BY 2 DESC" | tr '\n' ' '; echo
  "$OLD_BIN/psql" -h "$WORK" -p $PORT -U postgres -d test -X -qtA -c \
    "SELECT 'partitions: ' || count(*) FROM pg_class WHERE relpartbound IS NOT 
NULL
       AND relkind IN ('r', 'p')"
  "$OLD_BIN/pg_ctl" -D "$WORK/old" -m fast -w stop >/dev/null
  ;;
upgrade)
  S=${2:?side}; R=${3:?run}; B=$(side_bin "$S"); D=$WORK/new-$S; U=$WORK/up-$S
  rm -rf "$D" "$U"; mkdir -p "$U"
  "$B/initdb" -D "$D" -U postgres -A trust >/dev/null
  echo "max_locks_per_transaction = 4096" >>"$D/postgresql.conf"
  cd "$U"
  perf stat -x, -e instructions:u -o "$U/perf.csv" -- \
    "$B/pg_upgrade" -b "$OLD_BIN" -B "$B" -d "$WORK/old" -D "$D" -U postgres \
    --no-sync -s "$U" -p $PORT -P $((PORT + 1)) >"$U/pg_upgrade.out" 2>&1 \
    || { tail -20 "$U/pg_upgrade.out"; exit 1; }
  "$B/pg_ctl" -D "$D" -l "$U/server.log" -w -o "-p $PORT -k $U -c 
listen_addresses=''" start >/dev/null
  q() { "$B/psql" -h "$U" -p $PORT -U postgres -d test -X -qtA -F' ' -c "$1"; }
  c=$(q "SELECT seq_scan, seq_tup_read, coalesce(idx_scan, 0) FROM 
pg_stat_sys_tables
         WHERE relname = 'pg_constraint'")
  i=$(q "SELECT coalesce((SELECT idx_scan || ' ' || idx_tup_read FROM 
pg_stat_sys_indexes
         WHERE indexrelname = 'pg_constraint_confrelid_index'), '- -')")
  n=$(q "SELECT count(*) FROM pg_constraint")
  a=""
  if [ "$S" = v6 ]; then
    q "CREATE EXTENSION amcheck" >/dev/null
    a=$(q "SELECT bt_index_parent_check('pg_constraint_confrelid_index', true)" 
>/dev/null &&
        q "SELECT count(*) FROM pg_index i, LATERAL 
bt_index_check(i.indexrelid, true)
           WHERE i.indrelid = 'pg_constraint'::regclass" | sed 
's/^/amcheck_ok_indexes=/')
    q "DROP EXTENSION amcheck" >/dev/null
  fi
  "$B/pg_ctl" -D "$D" -m fast -w stop >/dev/null
  set -- $c; set -- "$@" $i
  echo "upgrade $S run=$R instructions=$(instr "$U/perf.csv") 
pg_constraint_rows=$n" \
       "seq_scan=$1 seq_tup_read=$2 idx_scan=$3 confrelid_idx_scan=$4" \
       "confrelid_idx_tup_read=$5 $a"
  ;;
truncate)
  S=${2:?side}; R=${3:?run}; B=$(side_bin "$S"); D=$WORK/new-$S; T=$WORK/tr-$S
  [ -d "$D" ] || { echo "run upgrade $S first"; exit 1; }
  mkdir -p "$T"
  sb() { "$B/postgres" --single -j -D "$D" -c stats_fetch_consistency=none 
test; }
  sb >/dev/null <<'SQL'
DROP TABLE IF EXISTS tr_fk, tr_pk, tr_plain;

CREATE TABLE tr_pk (id int PRIMARY KEY);

CREATE TABLE tr_fk (id int REFERENCES tr_pk);

CREATE TABLE tr_plain (id int);
SQL
  # workload <name> <setup sql> <one statement, run 100 times>
  workload() {
    local f=$T/$1.sql
    { printf 'SELECT pg_stat_force_next_flush();\n\n'
      printf 'SELECT 1;\n\n'
      printf '%s\n\n' "$2"
      printf 'SELECT pg_stat_force_next_flush();\n\n'
      printf 'SELECT 1;\n\n'
      printf 'SELECT seq_scan AS s0, seq_tup_read AS t0 FROM pg_stat_sys_tables 
WHERE relname = %s;\n\n' "'pg_constraint'"
      for _ in $(seq 100); do printf '%s\n\n' "$3"; done
      printf 'SELECT pg_stat_force_next_flush();\n\n'
      printf 'SELECT 1;\n\n'
      printf 'SELECT seq_scan AS s1, seq_tup_read AS t1 FROM pg_stat_sys_tables 
WHERE relname = %s;\n\n' "'pg_constraint'"
    } >"$f"
    perf stat -x, -e instructions:u -o "$T/$1.perf" -- \
      "$B/postgres" --single -j -D "$D" -c stats_fetch_consistency=none test 
<"$f" >"$T/$1.out" 2>&1
    echo "$(instr "$T/$1.perf") $(( $(val "$T/$1.out" s1) - $(val "$T/$1.out" 
s0) )) $(( $(val "$T/$1.out" t1) - $(val "$T/$1.out" t0) ))"
  }
  set -- $(workload trunc_fk 'SELECT 1;' 'TRUNCATE tr_pk, tr_fk;') \
         $(workload trunc_plain 'SELECT 1;' 'TRUNCATE tr_plain;') \
         $(workload commit_trg 'CREATE TEMP TABLE tt (a int) ON COMMIT DELETE 
ROWS;

CREATE FUNCTION pg_temp.f() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN RETURN 
NEW; END$$;

CREATE TRIGGER t BEFORE INSERT ON tt FOR EACH ROW EXECUTE FUNCTION 
pg_temp.f();' 'INSERT INTO tt VALUES (1);') \
         $(workload commit_plain 'CREATE TEMP TABLE tt (a int) ON COMMIT DELETE 
ROWS;' 'INSERT INTO tt VALUES (1);')
  rows=$(sb <<<'SELECT count(*) AS n FROM pg_constraint;' 2>/dev/null | sed -n 
's/.*n = "\([0-9]*\)".*/\1/p')
  sb >/dev/null <<<'DROP TABLE tr_fk, tr_pk, tr_plain;'
  echo "truncate $S run=$R pg_constraint_rows=$rows" \
       "x100 TRUNCATE pk,fk: instructions=$1 seq_scan=$2 seq_tup_read=$3;" \
       "x100 TRUNCATE plain (control): instructions=$4 seq_scan=$5 
seq_tup_read=$6;" \
       "x100 commit, on-commit temp w/ trigger: instructions=$7 seq_scan=$8 
seq_tup_read=$9;" \
       "x100 commit, on-commit temp plain (control): instructions=${10} 
seq_scan=${11} seq_tup_read=${12}"
  ;;
notnull)
  S=${2:?side}; R=${3:?run}; B=$(side_bin "$S"); D=$WORK/nn-$S
  rm -rf "$D"; "$B/initdb" -D "$D" -U postgres -A trust >/dev/null
  # 5 tables x 200 columns, NOT NULL or nullable, in one transaction each
  mk() {
    local f=$D.$1.sql
    for t in 1 2 3 4 5; do
      printf 'CREATE TABLE nn%s (%s);\n\n' $t \
        "$(seq -s, -f "c%g int $2" 1 200 | sed 's/,$//')"
    done >"$f"
    perf stat -x, -e instructions:u -o "$D.$1.perf" -- \
      "$B/postgres" --single -j -D "$D" postgres <"$f" >"$D.$1.out" 2>&1
    "$B/postgres" --single -j -D "$D" postgres >/dev/null 2>&1 \
      <<<'DROP TABLE nn1, nn2, nn3, nn4, nn5;'
    instr "$D.$1.perf"
  }
  nn=$(mk notnull 'NOT NULL'); nl=$(mk nullable '')
  echo "notnull $S run=$R fresh initdb: 1000 NOT NULL cols instructions=$nn," \
       "1000 nullable cols instructions=$nl, difference=$((nn - nl))"
  rm -rf "$D" "$D".*
  ;;
*) echo "stage?"; exit 2;;
esac

Reply via email to